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/quiet-moles-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-react-native': patch
---

Preserve Expo static exports and their per-asset Chunk IDs when wrapping Expo's Metro serializer.
35 changes: 35 additions & 0 deletions packages/react-native/src/tooling/posthogMetroSerializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { createDefaultMetroSerializer } from './vendor/metro/utils'
type SourceMap = Record<string, unknown>
type PostHogSerializerOptions = Parameters<MetroSerializer>[3] & {
posthogBundleCallback?: (bundle: Bundle) => Bundle
serializerOptions?: { output?: string }
}

const DEBUG_ID_PLACE_HOLDER = '__POSTHOG_CHUNK_ID__'
Expand Down Expand Up @@ -62,6 +63,8 @@ export function unstableBeforeAssetSerializationDebugIdPlugin({
/**
* Creates a Metro serializer that adds Chunk ID module to the plain bundle.
* The Chunk ID module is a virtual module that provides a Chunk ID in runtime.
* Expo static exports are delegated unchanged. Use getPostHogExpoConfig to
* enable PostHog Chunk IDs through Expo's per-asset serialization hook.
*
* RAM Bundles do not support custom serializers.
*/
Expand All @@ -72,6 +75,13 @@ export const createPostHogMetroSerializer = (customSerializer?: MetroSerializer)
return serializer(entryPoint, premodules, graph, options)
}

// Expo static exports can contain multiple assets (or JSON), not one plain
// bundle. Its per-asset PostHog plugin injects real IDs before source maps
// are generated. A placeholder here would prevent that plugin from running.
if (customSerializer && isExpoStaticExport(options)) {
return serializer(entryPoint, premodules, graph, options)
}

const debugIdModuleExists = premodules.some((module) => module.path === DEBUG_ID_MODULE_PATH)
if (debugIdModuleExists) {
// oxlint-disable-next-line no-console
Expand Down Expand Up @@ -127,6 +137,31 @@ export const createPostHogMetroSerializer = (customSerializer?: MetroSerializer)
}
}

function isExpoStaticExport(options: PostHogSerializerOptions): boolean {
// Match Expo's precedence: explicit serializer options override the URL,
// even when they do not specify an output mode.
if (options.serializerOptions) {
return options.serializerOptions.output === 'static'
}
if (!options.sourceUrl) {
return false
}

try {
const url = new URL(options.sourceUrl, 'https://expo.dev')
// JSC-safe URLs move the query into the path after //&. Only decode that
// form when there is no actual query, as Expo's serializer does.
const jscQueryStart = url.pathname.indexOf('//&')
const query =
!options.sourceUrl.split('#')[0].includes('?') && jscQueryStart !== -1
? new URLSearchParams(url.pathname.slice(jscQueryStart + 3))
: url.searchParams
return query.get('serializer.output') === 'static'
} catch {
return false
}
}

/**
* Called by the default Metro serializer after baseJSBundle has produced the
* final bundle but before source-map generation. That ordering is important:
Expand Down
100 changes: 99 additions & 1 deletion packages/react-native/test/posthogMetroSerializer.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { MixedOutput, Module } from 'metro'
import { createPostHogMetroSerializer } from '../src/tooling/posthogMetroSerializer'
import {
createPostHogMetroSerializer,
unstableBeforeAssetSerializationDebugIdPlugin,
} from '../src/tooling/posthogMetroSerializer'
import {
createDebugIdSnippet,
createVirtualJSModule,
Expand Down Expand Up @@ -127,6 +130,101 @@ describe('PostHog Metro serializer', () => {
consoleLogSpy.mockRestore()
})

describe('Expo static exports', () => {
test.each([
{ name: 'explicit options', options: { serializerOptions: { output: 'static' } } },
{
name: 'explicit static options overriding a plain URL',
options: {
serializerOptions: { output: 'static' },
sourceUrl: 'https://expo.dev/index.bundle?serializer.output=default',
},
},
{ name: 'source URL', options: { sourceUrl: 'https://expo.dev/index.bundle?serializer.output=static' } },
{ name: 'relative URL', options: { sourceUrl: '/index.bundle?serializer.output=static' } },
{ name: 'JSC-safe URL', options: { sourceUrl: 'https://expo.dev/index.bundle//&serializer.output=static' } },
])('delegates before injection with $name', async ({ options }) => {
const input = mockSerializerArgs(options, { transformOptions: { platform: 'ios' } })
const chunkId = '12345678-1234-4abc-8def-123456789abc'
const inner = vi.fn((...[_entryPoint, premodules, graph]: Parameters<MetroSerializer>) => {
const modules = unstableBeforeAssetSerializationDebugIdPlugin({
graph,
premodules: [...premodules],
debugId: chunkId,
})
const code = modules.map((module) => module.getSource().toString()).join('\n')
return {
artifacts: [
{ type: 'js', filename: 'index.js', source: code },
{ type: 'map', filename: 'index.js.map', source: JSON.stringify({ debugId: chunkId }) },
],
assets: [],
}
})
// Expo's static export result is outside Metro's plain-bundle return type.
const result = await createPostHogMetroSerializer(inner as unknown as MetroSerializer)(...input)
const exported = inner.mock.results[0].value

expect(result).toBe(exported)
expect(inner).toHaveBeenCalledTimes(1)
expect(inner.mock.calls[0][1]).toBe(input[1])
expect(inner).toHaveBeenCalledWith(...input)
expect(input[3]).not.toHaveProperty('posthogBundleCallback')
expect(determineDebugIdFromBundleSource(exported.artifacts[0].source)).toBe(chunkId)
expect(JSON.parse(exported.artifacts[1].source).debugId).toBe(chunkId)
expect(JSON.stringify(exported)).not.toContain('__POSTHOG_CHUNK_ID__')
})

test.each(['array', 'promised array', 'JSON', 'binary artifact'])('preserves %s output', async (shape) => {
const assets = [{ filename: 'index.js', source: 'console.log("app");' }]
const output =
shape === 'JSON'
? JSON.stringify({ artifacts: assets, assets: [] })
: shape === 'binary artifact'
? { artifacts: [{ filename: 'index.hbc', source: Buffer.from([0, 255, 1]) }], assets: [] }
: assets
const inner = vi.fn(() => (shape === 'promised array' ? Promise.resolve(output) : output))
const input = mockSerializerArgs({ serializerOptions: { output: 'static' } })
const result = await createPostHogMetroSerializer(inner as unknown as MetroSerializer)(...input)

expect(result).toBe(output)
expect(inner).toHaveBeenCalledTimes(1)
expect(inner).toHaveBeenCalledWith(...input)
expect(input[3]).not.toHaveProperty('posthogBundleCallback')
})

test.each([
{ serializerOptions: { output: 'default' }, sourceUrl: 'https://expo.dev/index.bundle?serializer.output=static' },
{ serializerOptions: {}, sourceUrl: 'https://expo.dev/index.bundle?serializer.output=static' },
{ sourceUrl: 'https://expo.dev/index.bundle?serializer.output=default' },
{ sourceUrl: 'https://expo.dev/index.bundle?other=serializer.output%3Dstatic' },
{ sourceUrl: 'https://expo.dev/index.bundle#serializer.output=static' },
{ sourceUrl: 'https://expo.dev/index.bundle//&serializer.output=static?serializer.output=default' },
{ sourceUrl: 'http://[' },
])('keeps the plain-bundle path for %j', async (options) => {
const inner = vi.fn<MetroSerializer>(() => ({ code: 'console.log("app");', map: '{}' }))
await expect(createPostHogMetroSerializer(inner)(...mockSerializerArgs(options))).rejects.toThrow(
'Chunk ID was not found in the bundle.'
)
expect(inner).toHaveBeenCalledTimes(1)
})

test('does not skip the default Metro serializer based on Expo options alone', async () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
try {
const result = await createPostHogMetroSerializer()(
...mockSerializerArgs({ serializerOptions: { output: 'static' } })
)
expect(typeof result).not.toBe('string')
if (typeof result !== 'string') {
expect(determineDebugIdFromBundleSource(result.code)).toMatch(UUID_PATTERN)
}
} finally {
log.mockRestore()
}
})
})

test('extracts the generated id when the runtime map uses a variable stack key', () => {
const chunkId = '12345678-1234-4abc-8def-123456789abc'
const snippet = createDebugIdSnippet(chunkId)
Expand Down