Skip to content

Commit 9ac489f

Browse files
committed
fix(react-native): expo-file-system detection broken on Expo SDK 54 stable
- Prefer new File/Paths API over legacy readAsStringAsync/writeAsStringAsync - Fix incorrect Paths.document.info().uri usage, use File(Paths.document, key) per Expo docs - Legacy fallback only used for SDK <=53 where new API doesn't exist - Works correctly across SDK 53, 54 (beta+stable), and 55 Closes #3151
1 parent d6fd9c9 commit 9ac489f

File tree

3 files changed

+209
-34
lines changed

3 files changed

+209
-34
lines changed

packages/react-native/src/native-deps.tsx

Lines changed: 40 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -160,44 +160,50 @@ export const buildOptimisiticAsyncStorage = (): PostHogCustomStorage | undefined
160160
// see https://github.com/PostHog/posthog-js-lite/blob/5fb7bee96f739b243dfea5589e2027f16629e8cd/posthog-react-native/src/optional/OptionalExpoFileSystem.ts#L7-L11
161161
const supportedPlatform = !isWeb() && !isMacOS()
162162

163-
// expo-54 uses expo-file-system v19 which removed the async methods and added new APIs
164-
// here we try to use the legacy package for back compatibility
165-
if (OptionalExpoFileSystemLegacy && supportedPlatform) {
166-
const filesystem = OptionalExpoFileSystemLegacy
167-
return buildLegacyStorage(filesystem)
168-
}
169-
170-
// expo-54 and expo-file-system v19 new APIs support
171-
if (OptionalExpoFileSystem && supportedPlatform) {
172-
const filesystem = OptionalExpoFileSystem
163+
// expo-file-system is only supported on native platforms (not web/macOS).
164+
// See https://github.com/PostHog/posthog-js-lite/issues/140
165+
if (supportedPlatform) {
166+
// expo >= 54 and expo-file-system >= 19: prefer the new File/Paths API.
167+
// We always check for the new API first because:
168+
// - SDK 54 stable exports legacy methods (readAsStringAsync, writeAsStringAsync) that throw
169+
// a deprecation error when called, so existence checks alone are unreliable.
170+
// - SDK 55+ has a working legacy subpath, but the new API is the recommended approach.
171+
// See https://github.com/PostHog/posthog-js/issues/3151
172+
if (OptionalExpoFileSystem) {
173+
const filesystem = OptionalExpoFileSystem as any
174+
175+
if (filesystem.Paths && filesystem.File) {
176+
return {
177+
async getItem(key: string) {
178+
try {
179+
// File constructor accepts Directory instances and joins path segments
180+
// See https://docs.expo.dev/versions/latest/sdk/filesystem/
181+
const file = new filesystem.File(filesystem.Paths.document, key)
182+
const stringContent = await file.text()
183+
return stringContent
184+
} catch (e) {
185+
return null
186+
}
187+
},
188+
189+
async setItem(key: string, value: string) {
190+
const file = new filesystem.File(filesystem.Paths.document, key)
191+
file.write(value)
192+
},
193+
}
194+
}
195+
}
173196

197+
// Fallback to legacy APIs for older Expo versions (expo <= 53, expo-file-system <= 18).
198+
// Try the legacy subpath first (available in SDK 55+), then the main module.
199+
// We validate that readAsStringAsync is a real function before using it,
200+
// to avoid calling deprecated stubs that throw at runtime (SDK 54 stable).
201+
const legacyModule = (OptionalExpoFileSystemLegacy || OptionalExpoFileSystem) as any
174202
try {
175-
const expoFileSystemLegacy = filesystem as any
176-
// identify legacy APIs with older versions (expo <= 53 and expo-file-system <= 18)
177-
if (expoFileSystemLegacy.readAsStringAsync) {
178-
return buildLegacyStorage(filesystem)
203+
if (legacyModule && typeof legacyModule.readAsStringAsync === 'function') {
204+
return buildLegacyStorage(legacyModule)
179205
}
180206
} catch (e) {}
181-
182-
// expo >= 54 and expo-file-system >= 19
183-
return {
184-
async getItem(key: string) {
185-
try {
186-
const uri = ((filesystem as any).Paths?.document.info().uri || '') + key
187-
const file = new (filesystem as any).File(uri)
188-
const stringContent = await file.text()
189-
return stringContent
190-
} catch (e) {
191-
return null
192-
}
193-
},
194-
195-
async setItem(key: string, value: string) {
196-
const uri = ((filesystem as any).Paths?.document.info().uri || '') + key
197-
const file = new (filesystem as any).File(uri)
198-
file.write(value)
199-
},
200-
}
201207
}
202208

203209
if (OptionalAsyncStorage) {
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Test for Expo SDK 54 stable where legacy methods exist but throw deprecation errors
2+
// and the new File/Paths API should be used instead.
3+
// See https://github.com/PostHog/posthog-js/issues/3151
4+
5+
const mockFileWrite = jest.fn()
6+
const mockFileText = jest.fn().mockResolvedValue('stored-value')
7+
const mockDocument = { uri: 'file:///mock-doc-dir/' }
8+
9+
// Mock expo-file-system as it appears in SDK 54 stable:
10+
// - Legacy methods exist but throw deprecation errors
11+
// - New File/Paths API is available
12+
jest.mock('../src/optional/OptionalExpoFileSystem', () => ({
13+
OptionalExpoFileSystem: {
14+
// Legacy methods that throw deprecation errors (SDK 54 stable behavior)
15+
readAsStringAsync: () => {
16+
throw new Error('Method readAsStringAsync imported from "expo-file-system" is deprecated')
17+
},
18+
writeAsStringAsync: () => {
19+
throw new Error('Method writeAsStringAsync imported from "expo-file-system" is deprecated')
20+
},
21+
documentDirectory: '/mock-doc-dir/',
22+
// New API (SDK 54+)
23+
Paths: {
24+
document: mockDocument,
25+
},
26+
// File constructor accepts (Directory, ...strings) and joins them
27+
File: jest.fn().mockImplementation((_dir: any, _key: string) => ({
28+
text: mockFileText,
29+
write: mockFileWrite,
30+
})),
31+
},
32+
}))
33+
34+
jest.mock('../src/optional/OptionalExpoFileSystemLegacy', () => ({
35+
OptionalExpoFileSystemLegacy: undefined,
36+
}))
37+
38+
jest.mock('../src/optional/OptionalAsyncStorage', () => ({
39+
OptionalAsyncStorage: undefined,
40+
}))
41+
42+
jest.mock('react-native', () => ({
43+
Platform: { OS: 'ios' },
44+
}))
45+
46+
import { buildOptimisiticAsyncStorage } from '../src/native-deps'
47+
import { OptionalExpoFileSystem } from '../src/optional/OptionalExpoFileSystem'
48+
49+
describe('Expo SDK 54 stable - new File API detection', () => {
50+
jest.useRealTimers()
51+
52+
beforeEach(() => {
53+
jest.clearAllMocks()
54+
mockFileText.mockResolvedValue('stored-value')
55+
})
56+
57+
it('should use new File/Paths API when both new and deprecated legacy APIs are present', () => {
58+
const storage = buildOptimisiticAsyncStorage()
59+
expect(storage).toBeDefined()
60+
})
61+
62+
it('should pass Paths.document directory and key to File constructor', async () => {
63+
const storage = buildOptimisiticAsyncStorage()!
64+
65+
const result = await storage.getItem('test-key')
66+
expect(result).toBe('stored-value')
67+
// File should be constructed with (Paths.document, key) per expo docs
68+
expect((OptionalExpoFileSystem as any).File).toHaveBeenCalledWith(mockDocument, 'test-key')
69+
})
70+
71+
it('should use File.write for setItem', () => {
72+
const storage = buildOptimisiticAsyncStorage()!
73+
74+
storage.setItem('test-key', 'test-value')
75+
expect((OptionalExpoFileSystem as any).File).toHaveBeenCalledWith(mockDocument, 'test-key')
76+
expect(mockFileWrite).toHaveBeenCalledWith('test-value')
77+
})
78+
79+
it('should return null when getItem fails', async () => {
80+
mockFileText.mockRejectedValue(new Error('File not found'))
81+
82+
const storage = buildOptimisiticAsyncStorage()!
83+
const result = await storage.getItem('nonexistent-key')
84+
expect(result).toBeNull()
85+
})
86+
})
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Test for Expo SDK 55 where both the new File/Paths API and a working legacy subpath exist.
2+
// The new API should always be preferred over the legacy subpath.
3+
4+
const mockFileWrite = jest.fn()
5+
const mockFileText = jest.fn().mockResolvedValue('stored-value')
6+
const mockDocument = { uri: 'file:///mock-doc-dir/' }
7+
8+
const mockLegacyReadAsStringAsync = jest.fn()
9+
const mockLegacyWriteAsStringAsync = jest.fn()
10+
11+
// Mock expo-file-system main module (SDK 55): has new File/Paths API + deprecated legacy stubs
12+
jest.mock('../src/optional/OptionalExpoFileSystem', () => ({
13+
OptionalExpoFileSystem: {
14+
// Deprecated legacy methods that throw at runtime
15+
readAsStringAsync: () => {
16+
throw new Error('Method readAsStringAsync imported from "expo-file-system" is deprecated')
17+
},
18+
writeAsStringAsync: () => {
19+
throw new Error('Method writeAsStringAsync imported from "expo-file-system" is deprecated')
20+
},
21+
documentDirectory: '/mock-doc-dir/',
22+
// New API
23+
Paths: {
24+
document: mockDocument,
25+
},
26+
File: jest.fn().mockImplementation((_dir: any, _key: string) => ({
27+
text: mockFileText,
28+
write: mockFileWrite,
29+
})),
30+
},
31+
}))
32+
33+
// Mock expo-file-system/legacy subpath (SDK 55): has working legacy methods
34+
jest.mock('../src/optional/OptionalExpoFileSystemLegacy', () => ({
35+
OptionalExpoFileSystemLegacy: {
36+
readAsStringAsync: mockLegacyReadAsStringAsync,
37+
writeAsStringAsync: mockLegacyWriteAsStringAsync,
38+
documentDirectory: '/mock-legacy-doc-dir/',
39+
},
40+
}))
41+
42+
jest.mock('../src/optional/OptionalAsyncStorage', () => ({
43+
OptionalAsyncStorage: undefined,
44+
}))
45+
46+
jest.mock('react-native', () => ({
47+
Platform: { OS: 'ios' },
48+
}))
49+
50+
import { buildOptimisiticAsyncStorage } from '../src/native-deps'
51+
import { OptionalExpoFileSystem } from '../src/optional/OptionalExpoFileSystem'
52+
53+
describe('Expo SDK 55 - prefers new File API over working legacy subpath', () => {
54+
jest.useRealTimers()
55+
56+
beforeEach(() => {
57+
jest.clearAllMocks()
58+
mockFileText.mockResolvedValue('stored-value')
59+
})
60+
61+
it('should use new File/Paths API even when legacy subpath is available', () => {
62+
const storage = buildOptimisiticAsyncStorage()
63+
expect(storage).toBeDefined()
64+
65+
// Verify it uses new API
66+
storage!.setItem('test-key', 'test-value')
67+
expect((OptionalExpoFileSystem as any).File).toHaveBeenCalledWith(mockDocument, 'test-key')
68+
expect(mockFileWrite).toHaveBeenCalledWith('test-value')
69+
70+
// Legacy methods should NOT be called
71+
expect(mockLegacyReadAsStringAsync).not.toHaveBeenCalled()
72+
expect(mockLegacyWriteAsStringAsync).not.toHaveBeenCalled()
73+
})
74+
75+
it('should read using new File API, not legacy', async () => {
76+
const storage = buildOptimisiticAsyncStorage()!
77+
78+
const result = await storage.getItem('test-key')
79+
expect(result).toBe('stored-value')
80+
expect((OptionalExpoFileSystem as any).File).toHaveBeenCalledWith(mockDocument, 'test-key')
81+
expect(mockLegacyReadAsStringAsync).not.toHaveBeenCalled()
82+
})
83+
})

0 commit comments

Comments
 (0)