Skip to content
Merged
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/all-plums-grab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-react-native': patch
---

fix: expo-file-system detection broken on Expo SDK 54 stable
76 changes: 41 additions & 35 deletions packages/react-native/src/native-deps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ const buildLegacyStorage = (filesystem: any): PostHogCustomStorage => {
}
}

export const buildOptimisiticAsyncStorage = (): PostHogCustomStorage | undefined => {
export const buildOptimisticAsyncStorage = (): PostHogCustomStorage | undefined => {
// On web platform during SSR (no window), skip file storage
// The caller will fall back to memory storage
if (isWeb() && typeof (GLOBAL_OBJ as any)?.window === 'undefined') {
Expand All @@ -160,44 +160,50 @@ export const buildOptimisiticAsyncStorage = (): PostHogCustomStorage | undefined
// see https://github.com/PostHog/posthog-js-lite/blob/5fb7bee96f739b243dfea5589e2027f16629e8cd/posthog-react-native/src/optional/OptionalExpoFileSystem.ts#L7-L11
const supportedPlatform = !isWeb() && !isMacOS()

// expo-54 uses expo-file-system v19 which removed the async methods and added new APIs
// here we try to use the legacy package for back compatibility
if (OptionalExpoFileSystemLegacy && supportedPlatform) {
const filesystem = OptionalExpoFileSystemLegacy
return buildLegacyStorage(filesystem)
}

// expo-54 and expo-file-system v19 new APIs support
if (OptionalExpoFileSystem && supportedPlatform) {
const filesystem = OptionalExpoFileSystem
// expo-file-system is only supported on native platforms (not web/macOS).
// See https://github.com/PostHog/posthog-js-lite/issues/140
if (supportedPlatform) {
// expo >= 54 and expo-file-system >= 19: prefer the new File/Paths API.
// We always check for the new API first because:
// - SDK 54 stable exports legacy methods (readAsStringAsync, writeAsStringAsync) that throw
// a deprecation error when called, so existence checks alone are unreliable.
// - SDK 55+ has a working legacy subpath, but the new API is the recommended approach.
// See https://github.com/PostHog/posthog-js/issues/3151
if (OptionalExpoFileSystem) {
const filesystem = OptionalExpoFileSystem as any

if (filesystem.Paths && filesystem.File) {
return {
async getItem(key: string) {
try {
// File constructor accepts Directory instances and joins path segments
// See https://docs.expo.dev/versions/latest/sdk/filesystem/
const file = new filesystem.File(filesystem.Paths.document, key)
const stringContent = await file.text()
return stringContent
} catch (e) {
return null
}
},

async setItem(key: string, value: string) {
const file = new filesystem.File(filesystem.Paths.document, key)
await file.write(value)
},
}
}
}

// Fallback to legacy APIs for older Expo versions (expo <= 53, expo-file-system <= 18).
// Try the legacy subpath first (available in SDK 55+), then the main module.
// We validate that readAsStringAsync is a real function before using it,
// to avoid calling deprecated stubs that throw at runtime (SDK 54 stable).
const legacyModule = (OptionalExpoFileSystemLegacy || OptionalExpoFileSystem) as any
try {
const expoFileSystemLegacy = filesystem as any
// identify legacy APIs with older versions (expo <= 53 and expo-file-system <= 18)
if (expoFileSystemLegacy.readAsStringAsync) {
return buildLegacyStorage(filesystem)
if (legacyModule && typeof legacyModule.readAsStringAsync === 'function') {
return buildLegacyStorage(legacyModule)
}
} catch (e) {}
Copy link
Contributor

Choose a reason for hiding this comment

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

We are completely silent here, should we log something?

Copy link
Member Author

Choose a reason for hiding this comment

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

mm why? its a valid path/supported path using older versions


// expo >= 54 and expo-file-system >= 19
return {
async getItem(key: string) {
try {
const uri = ((filesystem as any).Paths?.document.info().uri || '') + key
const file = new (filesystem as any).File(uri)
const stringContent = await file.text()
return stringContent
} catch (e) {
return null
}
},

async setItem(key: string, value: string) {
const uri = ((filesystem as any).Paths?.document.info().uri || '') + key
const file = new (filesystem as any).File(uri)
file.write(value)
},
}
}

if (OptionalAsyncStorage) {
Expand Down
4 changes: 2 additions & 2 deletions packages/react-native/src/posthog-rn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
} from '@posthog/core'
import { PostHogRNStorage, PostHogRNSyncMemoryStorage } from './storage'
import { version } from './version'
import { buildOptimisiticAsyncStorage, getAppProperties } from './native-deps'
import { buildOptimisticAsyncStorage, getAppProperties } from './native-deps'
import {
PostHogAutocaptureOptions,
PostHogCustomAppProperties,
Expand Down Expand Up @@ -183,7 +183,7 @@ export class PostHog extends PostHogCore {

let theStorage: PostHogCustomStorage | undefined
if (this._persistence === 'file') {
theStorage = options?.customStorage ?? buildOptimisiticAsyncStorage()
theStorage = options?.customStorage ?? buildOptimisticAsyncStorage()
}

if (theStorage) {
Expand Down
86 changes: 86 additions & 0 deletions packages/react-native/test/storage-expo54-stable.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Test for Expo SDK 54 stable where legacy methods exist but throw deprecation errors
// and the new File/Paths API should be used instead.
// See https://github.com/PostHog/posthog-js/issues/3151

const mockFileWrite = jest.fn()
const mockFileText = jest.fn().mockResolvedValue('stored-value')
const mockDocument = { uri: 'file:///mock-doc-dir/' }

// Mock expo-file-system as it appears in SDK 54 stable:
// - Legacy methods exist but throw deprecation errors
// - New File/Paths API is available
jest.mock('../src/optional/OptionalExpoFileSystem', () => ({
OptionalExpoFileSystem: {
// Legacy methods that throw deprecation errors (SDK 54 stable behavior)
readAsStringAsync: () => {
throw new Error('Method readAsStringAsync imported from "expo-file-system" is deprecated')
},
writeAsStringAsync: () => {
throw new Error('Method writeAsStringAsync imported from "expo-file-system" is deprecated')
},
documentDirectory: '/mock-doc-dir/',
// New API (SDK 54+)
Paths: {
document: mockDocument,
},
// File constructor accepts (Directory, ...strings) and joins them
File: jest.fn().mockImplementation((_dir: any, _key: string) => ({
text: mockFileText,
write: mockFileWrite,
})),
},
}))

jest.mock('../src/optional/OptionalExpoFileSystemLegacy', () => ({
OptionalExpoFileSystemLegacy: undefined,
}))

jest.mock('../src/optional/OptionalAsyncStorage', () => ({
OptionalAsyncStorage: undefined,
}))

jest.mock('react-native', () => ({
Platform: { OS: 'ios' },
}))

import { buildOptimisticAsyncStorage } from '../src/native-deps'
import { OptionalExpoFileSystem } from '../src/optional/OptionalExpoFileSystem'

describe('Expo SDK 54 stable - new File API detection', () => {
jest.useRealTimers()

beforeEach(() => {
jest.clearAllMocks()
mockFileText.mockResolvedValue('stored-value')
})

it('should use new File/Paths API when both new and deprecated legacy APIs are present', () => {
const storage = buildOptimisticAsyncStorage()
expect(storage).toBeDefined()
})

it('should pass Paths.document directory and key to File constructor', async () => {
const storage = buildOptimisticAsyncStorage()!

const result = await storage.getItem('test-key')
expect(result).toBe('stored-value')
// File should be constructed with (Paths.document, key) per expo docs
expect((OptionalExpoFileSystem as any).File).toHaveBeenCalledWith(mockDocument, 'test-key')
})

it('should use File.write for setItem', () => {
const storage = buildOptimisticAsyncStorage()!

storage.setItem('test-key', 'test-value')
expect((OptionalExpoFileSystem as any).File).toHaveBeenCalledWith(mockDocument, 'test-key')
expect(mockFileWrite).toHaveBeenCalledWith('test-value')
})

it('should return null when getItem fails', async () => {
mockFileText.mockRejectedValue(new Error('File not found'))

const storage = buildOptimisticAsyncStorage()!
const result = await storage.getItem('nonexistent-key')
expect(result).toBeNull()
})
})
83 changes: 83 additions & 0 deletions packages/react-native/test/storage-expo55.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Test for Expo SDK 55 where both the new File/Paths API and a working legacy subpath exist.
// The new API should always be preferred over the legacy subpath.

const mockFileWrite = jest.fn()
const mockFileText = jest.fn().mockResolvedValue('stored-value')
const mockDocument = { uri: 'file:///mock-doc-dir/' }

const mockLegacyReadAsStringAsync = jest.fn()
const mockLegacyWriteAsStringAsync = jest.fn()

// Mock expo-file-system main module (SDK 55): has new File/Paths API + deprecated legacy stubs
jest.mock('../src/optional/OptionalExpoFileSystem', () => ({
OptionalExpoFileSystem: {
// Deprecated legacy methods that throw at runtime
readAsStringAsync: () => {
throw new Error('Method readAsStringAsync imported from "expo-file-system" is deprecated')
},
writeAsStringAsync: () => {
throw new Error('Method writeAsStringAsync imported from "expo-file-system" is deprecated')
},
documentDirectory: '/mock-doc-dir/',
// New API
Paths: {
document: mockDocument,
},
File: jest.fn().mockImplementation((_dir: any, _key: string) => ({
text: mockFileText,
write: mockFileWrite,
})),
},
}))

// Mock expo-file-system/legacy subpath (SDK 55): has working legacy methods
jest.mock('../src/optional/OptionalExpoFileSystemLegacy', () => ({
OptionalExpoFileSystemLegacy: {
readAsStringAsync: mockLegacyReadAsStringAsync,
writeAsStringAsync: mockLegacyWriteAsStringAsync,
documentDirectory: '/mock-legacy-doc-dir/',
},
}))

jest.mock('../src/optional/OptionalAsyncStorage', () => ({
OptionalAsyncStorage: undefined,
}))

jest.mock('react-native', () => ({
Platform: { OS: 'ios' },
}))

import { buildOptimisticAsyncStorage } from '../src/native-deps'
import { OptionalExpoFileSystem } from '../src/optional/OptionalExpoFileSystem'

describe('Expo SDK 55 - prefers new File API over working legacy subpath', () => {
jest.useRealTimers()

beforeEach(() => {
jest.clearAllMocks()
mockFileText.mockResolvedValue('stored-value')
})

it('should use new File/Paths API even when legacy subpath is available', () => {
const storage = buildOptimisticAsyncStorage()
expect(storage).toBeDefined()

// Verify it uses new API
storage!.setItem('test-key', 'test-value')
expect((OptionalExpoFileSystem as any).File).toHaveBeenCalledWith(mockDocument, 'test-key')
expect(mockFileWrite).toHaveBeenCalledWith('test-value')

// Legacy methods should NOT be called
expect(mockLegacyReadAsStringAsync).not.toHaveBeenCalled()
expect(mockLegacyWriteAsStringAsync).not.toHaveBeenCalled()
})

it('should read using new File API, not legacy', async () => {
const storage = buildOptimisticAsyncStorage()!

const result = await storage.getItem('test-key')
expect(result).toBe('stored-value')
expect((OptionalExpoFileSystem as any).File).toHaveBeenCalledWith(mockDocument, 'test-key')
expect(mockLegacyReadAsStringAsync).not.toHaveBeenCalled()
})
})
4 changes: 2 additions & 2 deletions packages/react-native/test/storage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jest.mock('../src/optional/OptionalExpoFileSystem', () => ({
}))

import { PostHogRNStorage } from '../src/storage'
import { buildOptimisiticAsyncStorage } from '../src/native-deps'
import { buildOptimisticAsyncStorage } from '../src/native-deps'
import { OptionalExpoFileSystem } from '../src/optional/OptionalExpoFileSystem'

const mockedOptionalFileSystem = jest.mocked(OptionalExpoFileSystem, true)
Expand All @@ -35,7 +35,7 @@ describe('PostHog React Native', () => {
return res
})

storage = new PostHogRNStorage(buildOptimisiticAsyncStorage())
storage = new PostHogRNStorage(buildOptimisticAsyncStorage())
})

it('should load storage from the file system', async () => {
Expand Down
Loading