From 3a8ff98cc153d24683d4e3738c01aefead57ed27 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 7 Oct 2026 15:18:32 -0400 Subject: [PATCH 1/5] fix(surveys): reserve a survey before handing it to the delegate Android marked a survey active only once it was shown, so a repeat trigger event during surveyPopupDelaySeconds re-rendered it and the delegate restarted its delay. Reserve the survey before rendering, as iOS and the browser do, and release it when it is shown, closed, skipped or reset. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/patient-surveys-wait.md | 5 ++ .../surveys/PostHogSurveysIntegration.kt | 42 +++++++++++-- .../PostHogSurveysDelegateLifecycleTest.kt | 61 +++++++++++++++++++ .../surveys/PostHogSurveysEventPayloadTest.kt | 1 + 4 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 .changeset/patient-surveys-wait.md diff --git a/.changeset/patient-surveys-wait.md b/.changeset/patient-surveys-wait.md new file mode 100644 index 000000000..d99ad69a0 --- /dev/null +++ b/.changeset/patient-surveys-wait.md @@ -0,0 +1,5 @@ +--- +"posthog-android": patch +--- + +Fix repeat trigger events restarting a survey's `surveyPopupDelaySeconds` delay before it shows diff --git a/posthog-android/src/main/java/com/posthog/android/surveys/PostHogSurveysIntegration.kt b/posthog-android/src/main/java/com/posthog/android/surveys/PostHogSurveysIntegration.kt index c2eb1d931..fa3b491dc 100644 --- a/posthog-android/src/main/java/com/posthog/android/surveys/PostHogSurveysIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/surveys/PostHogSurveysIntegration.kt @@ -77,6 +77,10 @@ public class PostHogSurveysIntegration( private val resetGeneration: Long get() = progressStore.resetGeneration private val mainHandler = Handler(Looper.getMainLooper()) private var presentationGeneration = 0L + + // Handed to the delegate but not yet shown. Reserving here, like iOS and the browser, stops a + // repeat trigger event from re-rendering the survey and restarting the delegate's popup delay. + private var pendingPresentation: Long? = null private val lifecycleLock = Any() private var postHog: PostHogInterface? = null @@ -348,7 +352,16 @@ public class PostHogSurveysIntegration( return } - val (generation, presentation) = synchronized(activeSurveyLock) { resetGeneration to ++presentationGeneration } + val (generation, presentation) = + synchronized(activeSurveyLock) { + if (pendingPresentation != null) { + config.logger.log("Cannot show survey - another survey is already pending") + return + } + val presentation = ++presentationGeneration + pendingPresentation = presentation + resetGeneration to presentation + } val displayLanguage = resolveDisplayLanguage() val translations = resolveSurveyTranslations(survey, displayLanguage) val savedProgress = if (supportsSurveyResume()) progressStore.load(survey) else null @@ -373,11 +386,11 @@ public class PostHogSurveysIntegration( ).copy(initialQuestionIndex = progress.questionIndex) // Setup callbacks for delegate call - val onSurveyShown = surveyShownCallback(responseContext, progress) + val onSurveyShown = surveyShownCallback(responseContext, progress, presentation) val onSurveyResponse = surveyResponseCallback(responseContext) - val onSurveyClosed = surveyClosedCallback(responseContext) + val onSurveyClosed = surveyClosedCallback(responseContext, presentation) // Call the delegate to render the survey renderIfCurrent(generation, presentation) { @@ -392,18 +405,32 @@ public class PostHogSurveysIntegration( ) { runOnMain { val current = synchronized(activeSurveyLock) { generation == resetGeneration && presentation == presentationGeneration } - if (!synchronized(lifecycleLock) { isStarted } || !current) return@runOnMain + if (!synchronized(lifecycleLock) { isStarted } || !current) { + releasePendingPresentation(presentation) + return@runOnMain + } render() } } - private fun surveyClosedCallback(responseContext: SurveyResponseContext): OnPostHogSurveyClosed { + private fun releasePendingPresentation(presentation: Long) { + synchronized(activeSurveyLock) { + if (pendingPresentation == presentation) pendingPresentation = null + } + } + + private fun surveyClosedCallback( + responseContext: SurveyResponseContext, + presentation: Long, + ): OnPostHogSurveyClosed { return onSurveyClosed@{ _ -> // Store the original survey for branching logic val originalSurvey = responseContext.survey val distinctId = postHog?.distinctId() val event = synchronized(activeSurveyLock) { + releasePendingPresentation(presentation) + // Validate that this survey matches the currently active survey if (!isActiveAttempt(originalSurvey.id, responseContext.submissionId)) { config.logger.log("[Surveys] Received a close event for a non-active survey") @@ -476,6 +503,7 @@ public class PostHogSurveysIntegration( private fun surveyShownCallback( responseContext: SurveyResponseContext, progress: SurveyProgress, + presentation: Long, ): OnPostHogSurveyShown = { shownSurvey -> val originalSurvey = responseContext.survey @@ -485,6 +513,7 @@ public class PostHogSurveysIntegration( val distinctId = postHog?.distinctId() val event = synchronized(activeSurveyLock) { + releasePendingPresentation(presentation) if (!isCurrentContext(responseContext)) return@synchronized null activateSurvey(originalSurvey, progress, responseContext.wasRestored) @@ -833,7 +862,7 @@ public class PostHogSurveysIntegration( */ internal fun canShowNextSurvey(): Boolean { return synchronized(activeSurveyLock) { - config.cachePreferences?.isAvailable() != false && activeSurvey == null + config.cachePreferences?.isAvailable() != false && activeSurvey == null && pendingPresentation == null } } @@ -882,6 +911,7 @@ public class PostHogSurveysIntegration( private fun clearActiveSurvey() { synchronized(activeSurveyLock) { activeSurvey = null + pendingPresentation = null activeSubmissionId = null activeProgressWasPersisted = false activeSurveyCompleted = false diff --git a/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysDelegateLifecycleTest.kt b/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysDelegateLifecycleTest.kt index 32eb4d002..6a34b3546 100644 --- a/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysDelegateLifecycleTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysDelegateLifecycleTest.kt @@ -123,4 +123,65 @@ internal class PostHogSurveysDelegateLifecycleTest { integration.uninstall() } } + + @Test + fun `survey awaiting shown is not rendered again`() { + val shown = mutableListOf() + val closed = mutableListOf() + config.surveysConfig.surveysDelegate = recordingDelegate(shown, closed) + integration.install(PostHogFake()) + try { + integration.showSurvey(survey) + idleMainLooper() + integration.showSurvey(survey) + idleMainLooper() + assertEquals(1, shown.size) + + shown.single()(displaySurvey()) + closed.single()(displaySurvey()) + integration.showSurvey(survey) + idleMainLooper() + assertEquals(2, shown.size) + } finally { + integration.uninstall() + } + } + + @Test + fun `closing a survey before it shows releases it`() { + val shown = mutableListOf() + val closed = mutableListOf() + config.surveysConfig.surveysDelegate = recordingDelegate(shown, closed) + integration.install(PostHogFake()) + try { + integration.showSurvey(survey) + idleMainLooper() + closed.single()(displaySurvey()) + integration.showSurvey(survey) + idleMainLooper() + assertEquals(2, shown.size) + } finally { + integration.uninstall() + } + } + + private fun recordingDelegate( + shown: MutableList, + closed: MutableList, + ): PostHogSurveysDelegate = + object : PostHogSurveysDelegate by PostHogSurveysDefaultDelegate() { + override fun renderSurvey( + survey: PostHogDisplaySurvey, + onSurveyShown: OnPostHogSurveyShown, + onSurveyResponse: OnPostHogSurveyResponse, + onSurveyClosed: OnPostHogSurveyClosed, + ) { + shown += onSurveyShown + closed += onSurveyClosed + } + } + + private fun displaySurvey() = PostHogDisplaySurvey.toDisplaySurvey(survey) + + private fun idleMainLooper() = org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() } diff --git a/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysEventPayloadTest.kt b/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysEventPayloadTest.kt index ff3649fff..c3d6a6e04 100644 --- a/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysEventPayloadTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/surveys/PostHogSurveysEventPayloadTest.kt @@ -179,6 +179,7 @@ internal class PostHogSurveysEventPayloadTest { val staleDisplay = assertNotNull(delegate.shownSurvey) val staleShown = assertNotNull(delegate.onSurveyShown) preferences.clear() + assertNotNull(delegate.onSurveyClosed).invoke(staleDisplay) resumed.showSurvey(survey.copy(id = "new-survey")) val currentDisplay = assertNotNull(delegate.shownSurvey) assertNotNull(delegate.onSurveyShown).invoke(currentDisplay) From 4632557e8f146ed236d7f29d1b8b997952d0475a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 7 Oct 2026 15:55:48 -0400 Subject: [PATCH 2/5] fix(surveys): report a survey that fails to show as closed The integration now holds a survey until the delegate reports it shown or closed, so a delegate that drops one silently would block every later survey. The Compose host now reports a failed present as a close, and the delegate KDoc documents the contract. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/patient-surveys-wait.md | 1 + .../android/surveys/compose/internal/PostHogSurveyHost.kt | 3 +++ .../main/java/com/posthog/surveys/PostHogSurveysDelegate.kt | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/.changeset/patient-surveys-wait.md b/.changeset/patient-surveys-wait.md index d99ad69a0..65e1df307 100644 --- a/.changeset/patient-surveys-wait.md +++ b/.changeset/patient-surveys-wait.md @@ -1,5 +1,6 @@ --- "posthog-android": patch +"posthog-android-surveys-compose": patch --- Fix repeat trigger events restarting a survey's `surveyPopupDelaySeconds` delay before it shows diff --git a/posthog-android-surveys-compose/src/main/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHost.kt b/posthog-android-surveys-compose/src/main/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHost.kt index 6b9fedde1..e5e48401f 100644 --- a/posthog-android-surveys-compose/src/main/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHost.kt +++ b/posthog-android-surveys-compose/src/main/java/com/posthog/android/surveys/compose/internal/PostHogSurveyHost.kt @@ -235,7 +235,10 @@ internal class PostHogSurveyHost(private val activityProvider: ActivityProvider) } if (!presented) { + // The SDK holds this survey until it hears back, so report the failed show as a close. + val onClosed = onClosedCallback dismissInternal(notifyClosed = false) + onClosed?.invoke(survey) } } diff --git a/posthog/src/main/java/com/posthog/surveys/PostHogSurveysDelegate.kt b/posthog/src/main/java/com/posthog/surveys/PostHogSurveysDelegate.kt index ad5a53d29..fa7fa7646 100644 --- a/posthog/src/main/java/com/posthog/surveys/PostHogSurveysDelegate.kt +++ b/posthog/src/main/java/com/posthog/surveys/PostHogSurveysDelegate.kt @@ -12,6 +12,10 @@ public interface PostHogSurveysDelegate { * Delegates that opt in through [PostHogSurveysResumeAwareDelegate] must start at * [PostHogDisplaySurvey.initialQuestionIndex] to resume an unfinished survey. * + * No other survey is rendered until this one is shown and closed. If the survey can't be + * shown, call [onSurveyClosed] without [onSurveyShown]; the survey isn't marked as seen or + * dismissed and can be rendered again later. + * * @param survey The survey to be displayed to the user * @param onSurveyShown To be called when the survey is successfully displayed to the user * @param onSurveyResponse To be called when the user submits a response to a question From a3b038a718f8dc8c7f4789c7605b8ad663430bb1 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 7 Oct 2026 16:00:35 -0400 Subject: [PATCH 3/5] docs(surveys): note the delegate close contract in the changelog A delegate that drops a survey without calling onSurveyClosed now blocks later surveys, so call it out for custom delegates. Closing is enough to release the survey; it doesn't need to be shown first. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/surveys-delegate-close.md | 5 +++++ .../main/java/com/posthog/surveys/PostHogSurveysDelegate.kt | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/surveys-delegate-close.md diff --git a/.changeset/surveys-delegate-close.md b/.changeset/surveys-delegate-close.md new file mode 100644 index 000000000..7927a1cb5 --- /dev/null +++ b/.changeset/surveys-delegate-close.md @@ -0,0 +1,5 @@ +--- +"posthog-android": patch +--- + +Require a custom `PostHogSurveysDelegate` that can't show a survey to call `onSurveyClosed`, or later surveys won't render diff --git a/posthog/src/main/java/com/posthog/surveys/PostHogSurveysDelegate.kt b/posthog/src/main/java/com/posthog/surveys/PostHogSurveysDelegate.kt index fa7fa7646..58993b1cc 100644 --- a/posthog/src/main/java/com/posthog/surveys/PostHogSurveysDelegate.kt +++ b/posthog/src/main/java/com/posthog/surveys/PostHogSurveysDelegate.kt @@ -12,7 +12,7 @@ public interface PostHogSurveysDelegate { * Delegates that opt in through [PostHogSurveysResumeAwareDelegate] must start at * [PostHogDisplaySurvey.initialQuestionIndex] to resume an unfinished survey. * - * No other survey is rendered until this one is shown and closed. If the survey can't be + * No other survey is rendered until this one is closed. If the survey can't be * shown, call [onSurveyClosed] without [onSurveyShown]; the survey isn't marked as seen or * dismissed and can be rendered again later. * From 745a7a24306a0d483d8b9455365a4421de49d2f4 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 7 Oct 2026 16:05:14 -0400 Subject: [PATCH 4/5] chore: merge the survey changesets into one entry Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/patient-surveys-wait.md | 2 +- .changeset/surveys-delegate-close.md | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 .changeset/surveys-delegate-close.md diff --git a/.changeset/patient-surveys-wait.md b/.changeset/patient-surveys-wait.md index 65e1df307..822cc9d7c 100644 --- a/.changeset/patient-surveys-wait.md +++ b/.changeset/patient-surveys-wait.md @@ -3,4 +3,4 @@ "posthog-android-surveys-compose": patch --- -Fix repeat trigger events restarting a survey's `surveyPopupDelaySeconds` delay before it shows +Fix repeat trigger events restarting a survey's `surveyPopupDelaySeconds` delay; a custom `PostHogSurveysDelegate` that can't show a survey must now call `onSurveyClosed` diff --git a/.changeset/surveys-delegate-close.md b/.changeset/surveys-delegate-close.md deleted file mode 100644 index 7927a1cb5..000000000 --- a/.changeset/surveys-delegate-close.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"posthog-android": patch ---- - -Require a custom `PostHogSurveysDelegate` that can't show a survey to call `onSurveyClosed`, or later surveys won't render From 6928b6ca042e057aa8513d18c70ea77070192ef3 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 8 Oct 2026 11:55:02 -0400 Subject: [PATCH 5/5] docs(surveys): note the posthog_flutter 5.50.13 requirement in the changeset Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/patient-surveys-wait.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/patient-surveys-wait.md b/.changeset/patient-surveys-wait.md index 822cc9d7c..0c6bae893 100644 --- a/.changeset/patient-surveys-wait.md +++ b/.changeset/patient-surveys-wait.md @@ -3,4 +3,4 @@ "posthog-android-surveys-compose": patch --- -Fix repeat trigger events restarting a survey's `surveyPopupDelaySeconds` delay; a custom `PostHogSurveysDelegate` that can't show a survey must now call `onSurveyClosed` +Fix repeat trigger events restarting a survey's `surveyPopupDelaySeconds` delay; a custom `PostHogSurveysDelegate` that can't show a survey must now call `onSurveyClosed`. Flutter apps should use posthog_flutter 5.50.13 or later, since earlier versions can stop showing surveys until the app restarts.