From 942a0d2b75d67b373f1cc2af7cc054701d7c09d5 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Sat, 5 Sep 2026 08:46:43 +0200 Subject: [PATCH] fix(react-native): preserve wrapped Expo static exports --- .changeset/quiet-moles-export.md | 5 + .../src/tooling/posthogMetroSerializer.ts | 35 ++++++ .../test/posthogMetroSerializer.spec.ts | 100 +++++++++++++++++- 3 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 .changeset/quiet-moles-export.md diff --git a/.changeset/quiet-moles-export.md b/.changeset/quiet-moles-export.md new file mode 100644 index 0000000000..11298a0940 --- /dev/null +++ b/.changeset/quiet-moles-export.md @@ -0,0 +1,5 @@ +--- +'posthog-react-native': patch +--- + +Preserve Expo static exports and their per-asset Chunk IDs when wrapping Expo's Metro serializer. diff --git a/packages/react-native/src/tooling/posthogMetroSerializer.ts b/packages/react-native/src/tooling/posthogMetroSerializer.ts index e0a1efb476..4442b30948 100644 --- a/packages/react-native/src/tooling/posthogMetroSerializer.ts +++ b/packages/react-native/src/tooling/posthogMetroSerializer.ts @@ -19,6 +19,7 @@ import { createDefaultMetroSerializer } from './vendor/metro/utils' type SourceMap = Record type PostHogSerializerOptions = Parameters[3] & { posthogBundleCallback?: (bundle: Bundle) => Bundle + serializerOptions?: { output?: string } } const DEBUG_ID_PLACE_HOLDER = '__POSTHOG_CHUNK_ID__' @@ -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. */ @@ -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 @@ -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: diff --git a/packages/react-native/test/posthogMetroSerializer.spec.ts b/packages/react-native/test/posthogMetroSerializer.spec.ts index 4fd7572455..170e6c54c8 100644 --- a/packages/react-native/test/posthogMetroSerializer.spec.ts +++ b/packages/react-native/test/posthogMetroSerializer.spec.ts @@ -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, @@ -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) => { + 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(() => ({ 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)