From 34ffa5935e79fd267460a108a7f9c93e226a2e31 Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Thu, 14 May 2026 01:00:41 +0300 Subject: [PATCH] fix(nextjs-config): warn when withPostHogConfig is wrapped by another Next config wrapper When withPostHogConfig is not the outermost wrapper in next.config (e.g. withNextIntl(withPostHogConfig(...))), some outer wrappers silently drop the function-form config that withPostHogConfig returns. The result is that the webpack/compiler hooks are never installed, no source maps are generated or uploaded, and no errors or logs surface. This change registers a process.on('exit') handler that emits a clear warning if the inner Next.js config function was never invoked while sourcemaps are enabled, pointing the user to the correct wrapper order. It also documents the recommended order in the README. Generated-By: PostHog Code Task-Id: 689c4895-349a-4139-98c6-4ac93b522ae9 --- .changeset/nextjs-config-warn-on-misorder.md | 5 + packages/nextjs-config/README.md | 20 +++ packages/nextjs-config/src/config.ts | 26 ++++ packages/nextjs-config/tests/config.test.ts | 123 +++++++++++++++++++ packages/nextjs-config/tsconfig.json | 4 +- 5 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 .changeset/nextjs-config-warn-on-misorder.md create mode 100644 packages/nextjs-config/tests/config.test.ts 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/**/*"] }