Skip to content
Draft
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/nextjs-config-warn-on-misorder.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions packages/nextjs-config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
26 changes: 26 additions & 0 deletions packages/nextjs-config/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,28 @@ type NextFuncConfig = (phase: string, { defaultConfig }: { defaultConfig: NextCo
type NextAsyncConfig = (phase: string, { defaultConfig }: { defaultConfig: NextConfig }) => Promise<NextConfig>
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
Expand All @@ -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,
Expand Down
123 changes: 123 additions & 0 deletions packages/nextjs-config/tests/config.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
4 changes: 3 additions & 1 deletion packages/nextjs-config/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@
"target": "ES6",
"skipLibCheck": true,
"lib": ["DOM"]
}
},
"include": ["src/**/*"],
"exclude": ["tests/**/*"]
}
Loading