Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/giant-lamps-cover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-js': patch
---

use ph persistence instead of localStorage on surveys
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ test.describe('surveys - core display logic', () => {

expect(
await page.evaluate(() => {
return window.localStorage.getItem('seenSurvey_123')
// @ts-expect-error - posthog is added to window in test setup
return window.posthog.persistence.get_property('seenSurvey_123')
})
).toBeTruthy()

Expand Down Expand Up @@ -114,7 +115,8 @@ test.describe('surveys - core display logic', () => {

expect(
await page.evaluate(() => {
return window.localStorage.getItem('seenSurvey_123')
// @ts-expect-error - posthog is added to window in test setup
return window.posthog.persistence.get_property('seenSurvey_123')
})
).toBeTruthy()

Expand All @@ -129,7 +131,8 @@ test.describe('surveys - core display logic', () => {

expect(
await page.evaluate(() => {
return window.localStorage.getItem('seenSurvey_123')
// @ts-expect-error - posthog is added to window in test setup
return window.posthog.persistence.get_property('seenSurvey_123')
})
).toBeTruthy()
})
Expand Down Expand Up @@ -162,7 +165,8 @@ test.describe('surveys - core display logic', () => {
await expect(page.locator('.PostHogSurvey-123').locator('.survey-form')).toBeVisible()

const lastSeenDate = await page.evaluate(() => {
return window.localStorage.getItem('lastSeenSurveyDate')
// @ts-expect-error - posthog is added to window in test setup
return window.posthog.persistence.get_property('lastSeenSurveyDate')
})

expect(lastSeenDate!.split('T')[0]).toEqual(new Date().toISOString().split('T')[0])
Expand Down
179 changes: 179 additions & 0 deletions packages/browser/src/__tests__/extensions/surveys-persistence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { PostHogPersistence } from '../../posthog-persistence'
import { PostHogConfig } from '../../types'
import { setSurveySeenOnLocalStorage } from '../../utils/survey-utils'
import {
getSurveySeen,
setInProgressSurveyState,
getInProgressSurveyState,
clearInProgressSurveyState,
} from '../../extensions/surveys/surveys-extension-utils'
import { Survey, SurveyType, SurveySchedule } from '../../posthog-surveys-types'

describe('Surveys Persistence Migration', () => {
let instance: any

const config = {
token: 'test-token',
persistence: 'localStorage+cookie',
api_host: 'https://app.posthog.com',
} as PostHogConfig

const baseSurvey: Survey = {
id: 'test-survey',
name: 'Test Survey',
description: 'Test Description',
type: SurveyType.Popover,
questions: [],
appearance: null,
conditions: null,
start_date: null,
end_date: null,
current_iteration: null,
current_iteration_start_date: null,
feature_flag_keys: null,
linked_flag_key: null,
targeting_flag_key: null,
internal_targeting_flag_key: null,
}

beforeEach(() => {
localStorage.clear()
instance = {
config: { ...config },
persistence: new PostHogPersistence(config),
}
})

describe('Core persistence functionality', () => {
it('should write and read via persistence API', () => {
setSurveySeenOnLocalStorage(baseSurvey, instance)

expect(instance.persistence.get_property('seenSurvey_test-survey')).toBe(true)
expect(getSurveySeen(baseSurvey, instance)).toBe(true)
})

it('should handle complex JSON state', () => {
const state = {
surveySubmissionId: 'test-123',
lastQuestionIndex: 2,
responses: { $survey_response_q1: 'answer1' },
}

setInProgressSurveyState(baseSurvey, state, instance)
const retrieved = getInProgressSurveyState(baseSurvey, instance)

expect(retrieved).toEqual(state)
})

it('should clear state completely', () => {
const state = { surveySubmissionId: 'test-123', lastQuestionIndex: 1, responses: {} }
setInProgressSurveyState(baseSurvey, state, instance)

clearInProgressSurveyState(baseSurvey, instance)

expect(getInProgressSurveyState(baseSurvey, instance)).toBeNull()
})
})

describe('Backwards compatibility', () => {
it('should fallback to localStorage when no posthog instance', () => {
setSurveySeenOnLocalStorage(baseSurvey)

expect(localStorage.getItem('seenSurvey_test-survey')).toBe('true')
expect(getSurveySeen(baseSurvey)).toBe(true)
})

it('should fallback to localStorage when persistence disabled', () => {
const disabledInstance: any = {
config: { ...config, disable_persistence: true },
persistence: new PostHogPersistence({ ...config, disable_persistence: true }),
}

setSurveySeenOnLocalStorage(baseSurvey, disabledInstance)

expect(localStorage.getItem('seenSurvey_test-survey')).toBe('true')
expect(disabledInstance.persistence.isDisabled()).toBe(true)
})

it('should not require persistence.isDisabled to exist', () => {
const legacyInstance: any = {
config: { ...config },
persistence: new PostHogPersistence(config),
}
delete legacyInstance.persistence.isDisabled

expect(() => setSurveySeenOnLocalStorage(baseSurvey, legacyInstance)).not.toThrow()
expect(legacyInstance.persistence.get_property('seenSurvey_test-survey')).toBe(true)
})
})

describe('Migration from localStorage to persistence', () => {
it('should read old localStorage data when not yet in persistence', () => {
// CRITICAL: User has old data, new code deployed
localStorage.setItem('seenSurvey_test-survey', 'true')

const result = getSurveySeen(baseSurvey, instance)

expect(result).toBe(true) // Reads from localStorage
expect(instance.persistence.get_property('seenSurvey_test-survey')).toBeUndefined() // Not migrated yet
})

it('should prefer persistence over localStorage when both exist', () => {
// Edge case: Both sources have data
localStorage.setItem('seenSurvey_test-survey', 'false') // Stale
instance.persistence.set_property('seenSurvey_test-survey', true) // Current

const result = getSurveySeen(baseSurvey, instance)

expect(result).toBe(true) // Uses persistence
})

it('should read complex JSON state from localStorage without migrating yet', () => {
// Lazy migration: reads work, but data doesn't migrate until written
const state = {
surveySubmissionId: 'test-123',
lastQuestionIndex: 2,
responses: { $survey_response_q1: 'answer1' },
}
localStorage.setItem('inProgressSurvey_test-survey', JSON.stringify(state))

const retrieved = getInProgressSurveyState(baseSurvey, instance)

expect(retrieved).toEqual(state) // Read succeeds
expect(instance.persistence.get_property('inProgressSurvey_test-survey')).toBeUndefined() // Not migrated yet
})
})

describe('Regression protection', () => {
it('should handle survey iterations with correct keys', () => {
const surveyWithIteration = { ...baseSurvey, current_iteration: 2 }

setSurveySeenOnLocalStorage(surveyWithIteration, instance)

expect(instance.persistence.get_property('seenSurvey_test-survey_2')).toBe(true)
expect(instance.persistence.get_property('seenSurvey_test-survey')).toBeUndefined()
})

it('should respect repeatable survey behavior', () => {
const repeatableSurvey = { ...baseSurvey, schedule: SurveySchedule.Always }
instance.persistence.set_property('seenSurvey_test-survey', true)

const result = getSurveySeen(repeatableSurvey, instance)

expect(result).toBe(false) // Allows repeat activation
})
})

describe('Storage isolation', () => {
it('should use namespaced keys to avoid conflicts', () => {
// Documents that persistence and direct localStorage are isolated
instance.persistence.set_property('seenSurvey_test-survey', true)

// Direct localStorage doesn't see it (different key structure)
expect(localStorage.getItem('seenSurvey_test-survey')).toBeNull()

// Persistence uses namespaced key
expect(localStorage.getItem('ph_test-token_posthog')).toBeTruthy()
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,10 @@ jest.mock('../../../extensions/surveys/surveys-extension-utils', () => ({
// Mock uuidv7
jest.mock('../../../uuidv7')

// Mock PostHog instance needed by event handlers
const mockPosthog = {
capture: jest.fn(),
get_session_replay_url: jest.fn().mockReturnValue('http://example.com/replay'),
}

describe('SurveyPopup', () => {
let persistedBucket = {}
let mockPosthog: any

const mockSurvey: Survey = {
id: 'test-survey-partial',
name: 'Test Partial Survey',
Expand Down Expand Up @@ -89,6 +86,20 @@ describe('SurveyPopup', () => {

// Mock form.submit to prevent JSDOM error
HTMLFormElement.prototype.submit = jest.fn()

persistedBucket = {}

mockPosthog = {
capture: jest.fn(),
get_session_replay_url: jest.fn().mockReturnValue('http://example.com/replay'),
persistence: {
get_property: jest.fn((key) => persistedBucket[key]),
set_property: jest.fn((key, value) => {
persistedBucket[key] = value
}),
isDisabled: jest.fn().mockReturnValue(false),
},
}
})

afterEach(() => {
Expand Down Expand Up @@ -141,7 +152,7 @@ describe('SurveyPopup', () => {
)
expect(screen.getByText('Question 1')).toBeVisible()
expect(screen.getByRole('textbox')).toHaveValue('')
expect(mockedGetInProgressSurveyState).toHaveBeenCalledWith(mockSurvey)
expect(mockedGetInProgressSurveyState).toHaveBeenCalledWith(mockSurvey, mockPosthog)
expect(mockedUuidv7).toHaveBeenCalledTimes(1)
})

Expand All @@ -161,7 +172,7 @@ describe('SurveyPopup', () => {
)
expect(screen.getByText('Question 1')).toBeVisible()
expect(screen.getByRole('textbox')).toHaveValue('Previous answer')
expect(mockedGetInProgressSurveyState).toHaveBeenCalledWith(mockSurvey)
expect(mockedGetInProgressSurveyState).toHaveBeenCalledWith(mockSurvey, mockPosthog)
expect(mockedUuidv7).not.toHaveBeenCalled()
})

Expand Down
6 changes: 3 additions & 3 deletions packages/browser/src/__tests__/posthog-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { defaultPostHog } from './helpers/posthog-instance'
import type { PostHogConfig } from '../types'
import { uuidv7 } from '../uuidv7'
import { SurveyEventName, SurveyEventProperties } from '../posthog-surveys-types'
import { SURVEY_SEEN_PREFIX } from '../utils/survey-utils'
import { getFromPersistenceWithLocalStorageFallback, SURVEY_SEEN_PREFIX } from '../utils/survey-utils'
import { beforeEach } from '@jest/globals'

describe('posthog core', () => {
Expand Down Expand Up @@ -296,7 +296,7 @@ describe('posthog core', () => {
})

// assert
expect(localStorage.getItem(surveySeenKey)).toBe('true')
expect(getFromPersistenceWithLocalStorageFallback(surveySeenKey, posthog)).toBe(true)
// test if property contains at least $set but dont care about the other properties
expect(beforeSendMock.mock.calls[0][0]).toMatchObject({
properties: {
Expand Down Expand Up @@ -324,7 +324,7 @@ describe('posthog core', () => {
})

// assert
expect(localStorage.getItem(surveySeenKey)).toBe('true')
expect(getFromPersistenceWithLocalStorageFallback(surveySeenKey, posthog)).toBe(true)
// test if property contains at least $set but dont care about the other properties
expect(beforeSendMock.mock.calls[0][0]).toMatchObject({
properties: {
Expand Down
Loading