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
15 changes: 8 additions & 7 deletions packages/nextjs/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,18 @@ export function withSentryConfig(
userNextConfig: ExportedNextConfig = {},
userSentryWebpackPluginOptions: Partial<SentryWebpackPluginOptions> = {},
): NextConfigFunction | NextConfigObject {
const newWebpackConfig = constructWebpackConfigFunction(userNextConfig, userSentryWebpackPluginOptions);

// If the user has passed us a function, we need to return a function, so that we have access to `phase` and
// `defaults` in order to pass them along to the user's function
if (typeof userNextConfig === 'function') {
return (phase: string, defaults: { defaultConfig: { [key: string]: unknown } }): NextConfigObject => ({
...userNextConfig(phase, defaults),
webpack: newWebpackConfig,
});
return function(phase: string, defaults: { defaultConfig: { [key: string]: unknown } }): NextConfigObject {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: prefer an arrow function if we are just returning an anonymous func

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had one, then this return was getting visually muddied with the return inside the returned function, so I changed it to this, to make it clearer/easier to differentiate.

const materializedUserNextConfig = userNextConfig(phase, defaults);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a test case showing that we properly pass through phase?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏻

return {
...materializedUserNextConfig,
webpack: constructWebpackConfigFunction(materializedUserNextConfig, userSentryWebpackPluginOptions),
};
};
}

// Otherwise, we can just merge their config with ours and return an object.
return { ...userNextConfig, webpack: newWebpackConfig };
return { ...userNextConfig, webpack: constructWebpackConfigFunction(userNextConfig, userSentryWebpackPluginOptions) };
}
3 changes: 0 additions & 3 deletions packages/nextjs/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@ export { SentryCliPluginOptions as SentryWebpackPluginOptions } from '@sentry/we
export type ExportedNextConfig = NextConfigObject | NextConfigFunction;

export type NextConfigObject = {
// whether or not next should create source maps for browser code
// see: https://nextjs.org/docs/advanced-features/source-maps
productionBrowserSourceMaps?: boolean;
// custom webpack options
webpack?: WebpackConfigFunction;
} & {
Expand Down
15 changes: 8 additions & 7 deletions packages/nextjs/src/config/webpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
BuildContext,
EntryPointObject,
EntryPropertyObject,
ExportedNextConfig,
NextConfigObject,
SentryWebpackPluginOptions,
WebpackConfigFunction,
WebpackConfigObject,
Expand Down Expand Up @@ -50,22 +50,23 @@ const defaultSentryWebpackPluginOptions = dropUndefinedKeys({
* @returns The function to set as the nextjs config's `webpack` value
*/
export function constructWebpackConfigFunction(
userNextConfig: ExportedNextConfig = {},
userNextConfig: NextConfigObject = {},
userSentryWebpackPluginOptions: Partial<SentryWebpackPluginOptions> = {},
): WebpackConfigFunction {
const newWebpackFunction = (config: WebpackConfigObject, options: BuildContext): WebpackConfigObject => {
// clone to avoid mutability issues
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm of the opinion that we can remove this comment, I think most readers should be aware of why we spread into a new object.

Suggested change
// clone to avoid mutability issues

let newConfig = { ...config };

// if we're building server code, store the webpack output path as an env variable, so we know where to look for the
// webpack-processed version of `sentry.server.config.js` when we need it
if (config.target === 'node') {
storeServerConfigFileLocation(config);
if (newConfig.target === 'node') {
storeServerConfigFileLocation(newConfig);
}

let newConfig = config;

// if user has custom webpack config (which always takes the form of a function), run it so we have actual values to
// work with
if ('webpack' in userNextConfig && typeof userNextConfig.webpack === 'function') {
newConfig = userNextConfig.webpack(config, options);
newConfig = userNextConfig.webpack(newConfig, options);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be running storeServerConfigFileLocation after this logic?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just wanna avoid doing let newConfig if possible

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be running storeServerConfigFileLocation after this logic?

Either way works. I was thinking of cloning as a first step to immediately get out of the way.

I just wanna avoid doing let newConfig if possible

Given that we don't know if we're going to reassign it or not, the only way around using 'let' would be to move the storeServerConfigFileLocation call (which is soon to disappear anyway) and then use a ternary, which IMHO would be harder to read than the if, since in this case the condition we're checking is kinda long.

Curiosity: Is it just out of a general principle that immutability > mutability that you want to avoid let, or is there some other reason?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it just out of a general principle that immutability > mutability that you want to avoid

It's more specific to this instance. I made the comment because we are passing around config objects everywhere in this webpack logic, and I don't want someone to accidentally introduce a bug in future refactors or changes.

}

// Ensure quality source maps in production. (Source maps aren't uploaded in dev, and besides, Next doesn't let you
Expand Down
79 changes: 48 additions & 31 deletions packages/nextjs/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,16 @@ import {
SentryWebpackPluginOptions,
WebpackConfigObject,
} from '../src/config/types';
import { SENTRY_SERVER_CONFIG_FILE, SERVER_SDK_INIT_PATH, storeServerConfigFileLocation } from '../src/config/utils';
import { SENTRY_SERVER_CONFIG_FILE, SERVER_SDK_INIT_PATH } from '../src/config/utils';
import { constructWebpackConfigFunction, SentryWebpackPlugin } from '../src/config/webpack';

// mock `storeServerConfigFileLocation` in order to make it a no-op when necessary
jest.mock('../src/config/utils', () => {
const original = jest.requireActual('../src/config/utils');
return {
...original,
storeServerConfigFileLocation: jest.fn().mockImplementation(original.setRuntimeEnvVars),
// nuke this so it won't try to look for our dummy paths
storeServerConfigFileLocation: jest.fn(),
};
});

Expand All @@ -34,6 +35,10 @@ const userNextConfig = {
};
const userSentryWebpackPluginConfig = { org: 'squirrelChasers', project: 'simulator', include: './thirdPartyMaps' };

/** mocks of the arguments passed to the result of `withSentryConfig` (when it's a function) */
const runtimePhase = 'puppy-phase-chew-everything-in-sight';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Can we use a phase from next/constants that better reflects what this would be called with in real usage? As per phase docs in https://nextjs.org/docs/api-reference/next.config.js/introduction

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

¯\(ツ)/¯ We can, if you think it's important. Since they're values which are merely passed from our function to the user's function, they can really be anything. I find writing (and reading) tests to be kinda uninteresting, so I try to have some fun with it.

Regardless, I'm going to merge this now, in the interest of moving on.

const defaultNextConfig = { nappingHoursPerDay: 20, oversizeFeet: true, shouldChaseTail: true };
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Can we make this a more realistic config?


/** mocks of the arguments passed to `nextConfig.webpack` */
const serverWebpackConfig = {
entry: () => Promise.resolve({ 'pages/api/dogs/[name]': 'private-next-pages/api/dogs/[name].js' }),
Expand All @@ -47,7 +52,8 @@ const clientWebpackConfig = {
target: 'web',
context: '/Users/Maisey/projects/squirrelChasingSimulator',
};
const buildContext = { isServer: true, dev: false, buildId: 'doGsaREgReaT' };
const serverBuildContext = { isServer: true, dev: false, buildId: 'doGsaREgReaT' };
const clientBuildContext = { isServer: false, dev: false, buildId: 'doGsaREgReaT' };

/**
* Derive the final values of all next config options, by first applying `withSentryConfig` and then, if it returns a
Expand All @@ -61,16 +67,16 @@ const buildContext = { isServer: true, dev: false, buildId: 'doGsaREgReaT' };
*/
function materializeFinalNextConfig(
userNextConfig: ExportedNextConfig,
userSentryWebpackPluginConfig: SentryWebpackPluginOptions,
userSentryWebpackPluginConfig?: SentryWebpackPluginOptions,
): NextConfigObject {
const sentrifiedConfig = withSentryConfig(userNextConfig, userSentryWebpackPluginConfig);
let finalConfigValues = sentrifiedConfig;

if (typeof sentrifiedConfig === 'function') {
// for some reason TS won't recognize that `finalConfigValues` is now a NextConfigObject, which is why the cast
// below is necessary
finalConfigValues = sentrifiedConfig('phase-production-build', {
defaultConfig: {},
finalConfigValues = sentrifiedConfig(runtimePhase, {
defaultConfig: defaultNextConfig,
});
}

Expand All @@ -92,14 +98,25 @@ function materializeFinalNextConfig(
*/
async function materializeFinalWebpackConfig(options: {
userNextConfig: ExportedNextConfig;
userSentryWebpackPluginConfig: SentryWebpackPluginOptions;
userSentryWebpackPluginConfig?: SentryWebpackPluginOptions;
incomingWebpackConfig: WebpackConfigObject;
incomingWebpackBuildContext: BuildContext;
}): Promise<WebpackConfigObject> {
const { userNextConfig, userSentryWebpackPluginConfig, incomingWebpackConfig, incomingWebpackBuildContext } = options;

// if the user's next config is a function, run it so we have access to the values
const materializedUserNextConfig =
typeof userNextConfig === 'function'
? userNextConfig('phase-production-build', {
defaultConfig: {},
})
: userNextConfig;

// get the webpack config function we'd normally pass back to next
const webpackConfigFunction = constructWebpackConfigFunction(userNextConfig, userSentryWebpackPluginConfig);
const webpackConfigFunction = constructWebpackConfigFunction(
materializedUserNextConfig,
userSentryWebpackPluginConfig,
);

// call it to get concrete values for comparison
const finalWebpackConfigValue = webpackConfigFunction(incomingWebpackConfig, incomingWebpackBuildContext);
Expand All @@ -111,7 +128,7 @@ async function materializeFinalWebpackConfig(options: {

describe('withSentryConfig', () => {
it('includes expected properties', () => {
const finalConfig = materializeFinalNextConfig(userNextConfig, userSentryWebpackPluginConfig);
const finalConfig = materializeFinalNextConfig(userNextConfig);

expect(finalConfig).toEqual(
expect.objectContaining({
Expand All @@ -121,13 +138,13 @@ describe('withSentryConfig', () => {
});

it('preserves unrelated next config options', () => {
const finalConfig = materializeFinalNextConfig(userNextConfig, userSentryWebpackPluginConfig);
const finalConfig = materializeFinalNextConfig(userNextConfig);

expect(finalConfig.publicRuntimeConfig).toEqual(userNextConfig.publicRuntimeConfig);
});

it("works when user's overall config is an object", () => {
const finalConfig = materializeFinalNextConfig(userNextConfig, userSentryWebpackPluginConfig);
const finalConfig = materializeFinalNextConfig(userNextConfig);

expect(finalConfig).toEqual(
expect.objectContaining({
Expand All @@ -140,7 +157,7 @@ describe('withSentryConfig', () => {
it("works when user's overall config is a function", () => {
const userNextConfigFunction = () => userNextConfig;

const finalConfig = materializeFinalNextConfig(userNextConfigFunction, userSentryWebpackPluginConfig);
const finalConfig = materializeFinalNextConfig(userNextConfigFunction);

expect(finalConfig).toEqual(
expect.objectContaining({
Expand All @@ -149,20 +166,24 @@ describe('withSentryConfig', () => {
}),
);
});
});

describe('webpack config', () => {
beforeEach(() => {
// nuke this so it won't try to look for our dummy paths
(storeServerConfigFileLocation as jest.Mock).mockImplementationOnce(() => undefined);
it('correctly passes `phase` and `defaultConfig` through to functional `userNextConfig`', () => {
const userNextConfigFunction = jest.fn().mockReturnValue(userNextConfig);

materializeFinalNextConfig(userNextConfigFunction);

expect(userNextConfigFunction).toHaveBeenCalledWith(runtimePhase, {
defaultConfig: defaultNextConfig,
});
});
});

describe('webpack config', () => {
it('includes expected properties', async () => {
const finalWebpackConfig = await materializeFinalWebpackConfig({
userNextConfig,
userSentryWebpackPluginConfig,
incomingWebpackConfig: serverWebpackConfig,
incomingWebpackBuildContext: buildContext,
incomingWebpackBuildContext: serverBuildContext,
});

expect(finalWebpackConfig).toEqual(
Expand All @@ -177,14 +198,13 @@ describe('webpack config', () => {
it('preserves unrelated webpack config options', async () => {
const finalWebpackConfig = await materializeFinalWebpackConfig({
userNextConfig,
userSentryWebpackPluginConfig,
incomingWebpackConfig: serverWebpackConfig,
incomingWebpackBuildContext: buildContext,
incomingWebpackBuildContext: serverBuildContext,
});

// Run the user's webpack config function, so we can check the results against ours. Delete `entry` because we'll
// test it separately, and besides, it's one that we *should* be overwriting.
const materializedUserWebpackConfig = userNextConfig.webpack(serverWebpackConfig, buildContext);
const materializedUserWebpackConfig = userNextConfig.webpack(serverWebpackConfig, serverBuildContext);
// @ts-ignore `entry` may be required in real life, but we don't need it for our tests
delete materializedUserWebpackConfig.entry;

Expand All @@ -195,9 +215,8 @@ describe('webpack config', () => {
it('injects correct code when building server bundle', async () => {
const finalWebpackConfig = await materializeFinalWebpackConfig({
userNextConfig,
userSentryWebpackPluginConfig,
incomingWebpackConfig: serverWebpackConfig,
incomingWebpackBuildContext: buildContext,
incomingWebpackBuildContext: serverBuildContext,
});

expect(finalWebpackConfig.entry).toEqual(
Expand All @@ -210,9 +229,8 @@ describe('webpack config', () => {
it('injects correct code when building client bundle', async () => {
const finalWebpackConfig = await materializeFinalWebpackConfig({
userNextConfig,
userSentryWebpackPluginConfig,
incomingWebpackConfig: clientWebpackConfig,
incomingWebpackBuildContext: { ...buildContext, isServer: false },
incomingWebpackBuildContext: clientBuildContext,
});

expect(finalWebpackConfig.entry).toEqual(
Expand All @@ -224,12 +242,11 @@ describe('webpack config', () => {
it('handles non-empty `main.js` entry point', async () => {
const finalWebpackConfig = await materializeFinalWebpackConfig({
userNextConfig,
userSentryWebpackPluginConfig,
incomingWebpackConfig: {
...clientWebpackConfig,
entry: () => Promise.resolve({ main: './src/index.ts', 'main.js': ['sitLieDownRollOver.config.js'] }),
},
incomingWebpackBuildContext: { ...buildContext, isServer: false },
incomingWebpackBuildContext: clientBuildContext,
});

expect(finalWebpackConfig.entry).toEqual(
Expand All @@ -244,15 +261,15 @@ describe('webpack config', () => {

describe('Sentry webpack plugin config', () => {
it('includes expected properties', () => {
// pass
// TODO
});

it('preserves unrelated plugin config options', () => {
// pass
// TODO
});

it('warns when overriding certain default values', () => {
// pass
// TODO
});

it("merges default include and ignore/ignoreFile options with user's values", () => {
Expand Down