diff --git a/.changeset/nextjs-config-warn-on-misorder.md b/.changeset/nextjs-config-warn-on-misorder.md new file mode 100644 index 0000000000..5ff4dd33af --- /dev/null +++ b/.changeset/nextjs-config-warn-on-misorder.md @@ -0,0 +1,5 @@ +--- +'@posthog/nextjs-config': patch +--- + +Warn at process exit when `withPostHogConfig` is wrapped by another Next.js config wrapper that drops its function-form return value. Previously this misconfiguration silently disabled source map generation and upload with no logs or errors. Also documents the correct wrapper ordering in the README. diff --git a/packages/nextjs-config/README.md b/packages/nextjs-config/README.md index d740ac51cf..6c77b81935 100644 --- a/packages/nextjs-config/README.md +++ b/packages/nextjs-config/README.md @@ -28,6 +28,26 @@ export default withPostHogConfig(nextConfig, { }) ``` +## Combining with other Next.js config wrappers + +`withPostHogConfig` returns a function-form Next.js config. Some other wrappers +(e.g. `next-intl`, `next-mdx`) do not correctly forward function-form configs, +which would silently drop PostHog's webpack/compiler hooks — no source maps +would be generated or uploaded, and no errors would appear. + +To avoid this, make `withPostHogConfig` the **outermost** wrapper: + +```typescript +// ✅ Correct: withPostHogConfig is the outermost wrapper +export default withPostHogConfig(withNextIntl(nextConfig), { ... }) + +// ❌ Incorrect: another wrapper around withPostHogConfig may silently drop it +export default withNextIntl(withPostHogConfig(nextConfig, { ... })) +``` + +If the inner config function is never invoked during a build, +`@posthog/nextjs-config` will emit a warning at process exit pointing this out. + ## Questions? ### [Check out our community page.](https://posthog.com/posts) diff --git a/packages/nextjs-config/src/config.ts b/packages/nextjs-config/src/config.ts index df72a7005b..5544b9472f 100644 --- a/packages/nextjs-config/src/config.ts +++ b/packages/nextjs-config/src/config.ts @@ -6,6 +6,28 @@ type NextFuncConfig = (phase: string, { defaultConfig }: { defaultConfig: NextCo type NextAsyncConfig = (phase: string, { defaultConfig }: { defaultConfig: NextConfig }) => Promise type UserProvidedConfig = NextConfig | NextFuncConfig | NextAsyncConfig +let invocationTrackingRegistered = false +let innerConfigInvoked = false + +function registerInvocationCheck(): void { + if (invocationTrackingRegistered) { + return + } + invocationTrackingRegistered = true + process.on('exit', () => { + if (!innerConfigInvoked) { + console.warn( + '[@posthog/nextjs-config] withPostHogConfig was called, but its inner Next.js config function was never invoked. ' + + 'This usually means another Next.js config wrapper (e.g. withNextIntl, withMDX) is being applied around withPostHogConfig ' + + 'and is not forwarding the function-form config that withPostHogConfig returns. ' + + 'As a result, no source maps were generated or uploaded. ' + + 'Fix: make withPostHogConfig the OUTERMOST wrapper, e.g. ' + + '`export default withPostHogConfig(withNextIntl(nextConfig), { ... })`.' + ) + } + }) +} + export function withPostHogConfig(userNextConfig: UserProvidedConfig, posthogConfig: PluginConfig): NextConfig { const resolvedConfig = resolveConfig(posthogConfig) const sourceMapEnabled = resolvedConfig.sourcemaps.enabled @@ -14,7 +36,11 @@ export function withPostHogConfig(userNextConfig: UserProvidedConfig, posthogCon if (turbopackEnabled && !isCompilerHookSupported) { console.warn('[@posthog/nextjs-config] Turbopack support is only available with next version >= 15.4.1') } + if (sourceMapEnabled) { + registerInvocationCheck() + } return async (phase: string, { defaultConfig }: { defaultConfig: NextConfig }) => { + innerConfigInvoked = true const { webpack: userWebPackConfig, compiler: userCompilerConfig, diff --git a/packages/nextjs-config/tests/config.test.ts b/packages/nextjs-config/tests/config.test.ts new file mode 100644 index 0000000000..37cdf51670 --- /dev/null +++ b/packages/nextjs-config/tests/config.test.ts @@ -0,0 +1,123 @@ +/** + * Tests for withPostHogConfig — focused on the "warn when wrapped" behavior. + * + * The bug: when withPostHogConfig is not the outermost wrapper in next.config.js, + * outer wrappers that spread a function-form config silently drop it. The user + * sees no source maps and no logs. We add a process.on('exit') warning to make + * this misconfiguration visible. + */ + +jest.mock('@posthog/webpack-plugin', () => ({ + PosthogWebpackPlugin: class {}, + resolveConfig: (cfg: any) => ({ + ...cfg, + sourcemaps: { + enabled: cfg?.sourcemaps?.enabled ?? false, + deleteAfterUpload: cfg?.sourcemaps?.deleteAfterUpload ?? true, + }, + }), +})) + +jest.mock('../src/utils', () => ({ + hasCompilerHook: () => true, + isTurbopackEnabled: () => false, + processSourceMaps: async () => {}, +})) + +describe('withPostHogConfig - misorder detection', () => { + let exitListeners: Array<() => void> + let originalProcessOn: typeof process.on + let warnSpy: jest.SpyInstance + + beforeEach(() => { + jest.resetModules() + exitListeners = [] + originalProcessOn = process.on.bind(process) + // Capture 'exit' listeners so we can trigger them deterministically + // without actually exiting the test process. + jest.spyOn(process, 'on').mockImplementation(((event: string, listener: () => void) => { + if (event === 'exit') { + exitListeners.push(listener) + return process + } + return originalProcessOn(event as any, listener as any) + }) as any) + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + function loadFreshModule(): { withPostHogConfig: typeof import('../src/config').withPostHogConfig } { + let mod!: { withPostHogConfig: typeof import('../src/config').withPostHogConfig } + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + mod = require('../src/config') + }) + return mod + } + + it('does NOT warn when the returned config function is invoked (correct usage)', async () => { + const { withPostHogConfig: freshWith } = loadFreshModule() + const wrapped = freshWith( + { reactStrictMode: true } as any, + { + personalApiKey: 'phx_test', + envId: 'test-env', + host: 'https://us.posthog.com', + sourcemaps: { enabled: true }, + } as any + ) + + // Simulate Next.js calling the function-form config (correct behavior). + await (wrapped as any)('phase-production-build', { defaultConfig: {} }) + + // Trigger captured exit handler(s). + for (const listener of exitListeners) { + listener() + } + + expect(warnSpy).not.toHaveBeenCalledWith( + expect.stringContaining('inner Next.js config function was never invoked') + ) + }) + + it('warns when the returned config function is never invoked (misordered wrappers)', () => { + const { withPostHogConfig: freshWith } = loadFreshModule() + // Simulate an outer wrapper that drops the function, e.g. `{ ...withPostHogConfig(...) }`. + const wrapped = freshWith( + { reactStrictMode: true } as any, + { + personalApiKey: 'phx_test', + envId: 'test-env', + host: 'https://us.posthog.com', + sourcemaps: { enabled: true }, + } as any + ) + void wrapped // deliberately never invoked + + for (const listener of exitListeners) { + listener() + } + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('inner Next.js config function was never invoked') + ) + }) + + it('does NOT register the exit handler when sourcemaps are disabled', () => { + const { withPostHogConfig: freshWith } = loadFreshModule() + freshWith( + { reactStrictMode: true } as any, + { + personalApiKey: 'phx_test', + envId: 'test-env', + host: 'https://us.posthog.com', + sourcemaps: { enabled: false }, + } as any + ) + + expect(exitListeners).toHaveLength(0) + }) +}) diff --git a/packages/nextjs-config/tsconfig.json b/packages/nextjs-config/tsconfig.json index 4dac18ba64..1e633f6158 100644 --- a/packages/nextjs-config/tsconfig.json +++ b/packages/nextjs-config/tsconfig.json @@ -7,5 +7,7 @@ "target": "ES6", "skipLibCheck": true, "lib": ["DOM"] - } + }, + "include": ["src/**/*"], + "exclude": ["tests/**/*"] }