Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CLI: Add Next.js framework automigration #19574

Merged
merged 6 commits into from
Dec 14, 2022
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
2 changes: 2 additions & 0 deletions code/lib/cli/src/automigrate/fixes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { eslintPlugin } from './eslint-plugin';
import { builderVite } from './builder-vite';
import { sbScripts } from './sb-scripts';
import { sbBinary } from './sb-binary';
import { nextjsFramework } from './nextjs-framework';
import { newFrameworks } from './new-frameworks';
import { removedGlobalClientAPIs } from './remove-global-client-apis';
import { mdx1to2 } from './mdx-1-to-2';
Expand All @@ -30,6 +31,7 @@ export const fixes: Fix[] = [
sbBinary,
sbScripts,
newFrameworks,
nextjsFramework,
removedGlobalClientAPIs,
mdx1to2,
docsPageAutomatic,
Expand Down
183 changes: 183 additions & 0 deletions code/lib/cli/src/automigrate/fixes/nextjs-framework.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/* eslint-disable no-underscore-dangle */
import path from 'path';
import type { JsPackageManager } from '../../js-package-manager';
import { nextjsFramework } from './nextjs-framework';

// eslint-disable-next-line global-require, jest/no-mocks-import
jest.mock('fs-extra', () => require('../../../../../__mocks__/fs-extra'));

const checkNextjsFramework = async ({ packageJson, main }: any) => {
if (main) {
// eslint-disable-next-line global-require
require('fs-extra').__setMockFiles({
[path.join('.storybook', 'main.js')]: `module.exports = ${JSON.stringify(main)};`,
});
}
const packageManager = {
retrievePackageJson: () => ({ dependencies: {}, devDependencies: {}, ...packageJson }),
} as JsPackageManager;
return nextjsFramework.check({ packageManager });
};

describe('nextjs-framework fix', () => {
describe('should no-op', () => {
it('in sb < 7', async () => {
const packageJson = { dependencies: { '@storybook/react': '^6.2.0' } };
await expect(
checkNextjsFramework({
packageJson,
main: {},
})
).resolves.toBeFalsy();
});

it('in sb 7 with no main', async () => {
const packageJson = { dependencies: { '@storybook/react': '^7.0.0' } };
await expect(
checkNextjsFramework({
packageJson,
main: undefined,
})
).resolves.toBeFalsy();
});

it('in sb 7 with no framework field in main', async () => {
const packageJson = { dependencies: { '@storybook/react': '^7.0.0' } };
await expect(
checkNextjsFramework({
packageJson,
main: {},
})
).resolves.toBeFalsy();
});

it('in sb 7 in non-nextjs projects', async () => {
const packageJson = { dependencies: { '@storybook/react': '^7.0.0' } };
await expect(
checkNextjsFramework({
packageJson,
main: {
framework: '@storybook/react',
},
})
).resolves.toBeFalsy();
});

it('in sb 7 with unsupported package', async () => {
const packageJson = { dependencies: { '@storybook/riot': '^7.0.0' } };
await expect(
checkNextjsFramework({
packageJson,
main: {
framework: '@storybook/riot',
core: {
builder: 'webpack5',
},
},
})
).resolves.toBeFalsy();
});
});

describe('sb >= 7', () => {
it('should update from @storybook/react-webpack5 to @storybook/nextjs', async () => {
const packageJson = {
dependencies: {
'@storybook/react': '^7.0.0-alpha.0',
'@storybook/react-webpack5': '^7.0.0-alpha.0',
next: '^12.0.0',
},
};
await expect(
checkNextjsFramework({
packageJson,
main: {
framework: '@storybook/react-webpack5',
},
})
).resolves.toEqual(expect.objectContaining({}));
});

it('should remove legacy addons', async () => {
const packageJson = {
dependencies: {
'@storybook/react': '^7.0.0-alpha.0',
'@storybook/react-webpack5': '^7.0.0-alpha.0',
next: '^12.0.0',
'storybook-addon-next': '^1.0.0',
'storybook-addon-next-router': '^1.0.0',
},
};
await expect(
checkNextjsFramework({
packageJson,
main: {
framework: '@storybook/react-webpack5',
addons: ['storybook-addon-next', 'storybook-addon-next-router'],
},
})
).resolves.toEqual(
expect.objectContaining({
addonsToRemove: ['storybook-addon-next', 'storybook-addon-next-router'],
})
);
});

it('should move nextjs addon options to frameworkOptions', async () => {
const packageJson = {
dependencies: {
'@storybook/react': '^7.0.0-alpha.0',
'@storybook/react-webpack5': '^7.0.0-alpha.0',
next: '^12.0.0',
'storybook-addon-next': '^1.0.0',
},
};
await expect(
checkNextjsFramework({
packageJson,
main: {
framework: { name: '@storybook/react-webpack5', options: { fastRefresh: true } },
addons: [
{
name: 'storybook-addon-next',
options: {
nextConfigPath: '../next.config.js',
},
},
],
},
})
).resolves.toEqual(
expect.objectContaining({
addonsToRemove: ['storybook-addon-next'],
frameworkOptions: {
fastRefresh: true,
nextConfigPath: '../next.config.js',
},
})
);
});

it('should warn for @storybook/react-vite users', async () => {
const consoleSpy = jest.spyOn(console, 'info');
const packageJson = {
dependencies: {
'@storybook/react': '^7.0.0-alpha.0',
'@storybook/react-vite': '^7.0.0-alpha.0',
next: '^12.0.0',
'storybook-addon-next': '^1.0.0',
},
};
await expect(
checkNextjsFramework({
packageJson,
main: {
framework: { name: '@storybook/react-vite' },
},
})
).resolves.toBeFalsy();

expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Vite builder'));
});
});
});
199 changes: 199 additions & 0 deletions code/lib/cli/src/automigrate/fixes/nextjs-framework.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import chalk from 'chalk';
import dedent from 'ts-dedent';
import semver from 'semver';
import type { ConfigFile } from '@storybook/csf-tools';
import { readConfig, writeConfig } from '@storybook/csf-tools';
import { getStorybookInfo } from '@storybook/core-common';

import type { Fix } from '../types';
import type { PackageJsonWithDepsAndDevDeps } from '../../js-package-manager';
import { getStorybookVersionSpecifier } from '../../helpers';

const logger = console;

interface NextjsFrameworkRunOptions {
main: ConfigFile;
packageJson: PackageJsonWithDepsAndDevDeps;
addonsToRemove: string[];
frameworkOptions: Record<string, any>;
}

type Addon = string | { name: string; options?: Record<string, any> };

export const getNextjsAddonOptions = (addons: Addon[]) => {
const nextjsAddon = addons?.find((addon) =>
typeof addon === 'string'
? addon === 'storybook-addon-next'
: addon.name === 'storybook-addon-next'
);

if (!nextjsAddon || typeof nextjsAddon === 'string') {
return {};
}

return nextjsAddon.options || {};
};

/**
* Does the user have a nextjs project but is not using the @storybook/nextjs framework package?
*
* If so:
* - Remove the dependencies if webpack (@storybook/react-webpack5)
* - Install the nextjs package (@storybook/nextjs)
* - Uninstall existing legacy addons: storybook-addon-next and storybook-addon-next-router
* - Update StorybookConfig type import (if it exists) from react-webpack5 to nextjs
* - Update the main config to use the new framework
* -- removing legacy addons: storybook-addon-next and storybook-addon-next-router
* -- moving storybook-addon-next options into frameworkOptions
*/
export const nextjsFramework: Fix<NextjsFrameworkRunOptions> = {
id: 'nextjsFramework',

async check({ packageManager }) {
const packageJson = packageManager.retrievePackageJson();
const allDeps = { ...packageJson.dependencies, ...packageJson.devDependencies };

if (!allDeps.next) {
return null;
}

const { mainConfig, version: storybookVersion } = getStorybookInfo(packageJson);
if (!mainConfig) {
logger.warn('Unable to find storybook main.js config, skipping');
return null;
}

const storybookCoerced = storybookVersion && semver.coerce(storybookVersion)?.version;
if (!storybookCoerced) {
logger.warn(dedent`
❌ Unable to determine storybook version, skipping ${chalk.cyan('nextjsFramework')} fix.
🤔 Are you running automigrate from your project directory?
`);
return null;
}

if (!semver.gte(storybookCoerced, '7.0.0')) {
return null;
}

const main = await readConfig(mainConfig);

const frameworkPackage = main.getFieldValue(['framework']);

if (!frameworkPackage) {
return null;
}

const frameworkPackageName =
typeof frameworkPackage === 'string' ? frameworkPackage : frameworkPackage.name;

if (frameworkPackageName === '@storybook/react-vite') {
logger.info(dedent`
We've detected you are using Storybook in a Next.js project.

In Storybook 7, we introduced a new framework package for Next.js projects: @storybook/nextjs.

This package provides a better experience for Next.js users, however it is only compatible with the webpack 5 builder, so we can't automigrate for you, as you are using the Vite builder.

If you are interested in using this package, see: ${chalk.yellow(
'https://github.com/storybookjs/storybook/blob/next/code/frameworks/nextjs/README.md'
)}
`);

return null;
}

// we only migrate from react-webpack5 projects
Copy link
Contributor

Choose a reason for hiding this comment

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

Given that @storybook/react-webpack5 is only available in 7.0+, It seems far more likely that someone would be upgrading from a project with @storybook/react & @storybook/builder-webpack5. Perhaps, for this scenario, we could leverage the fix tested here, and then re-run this fix?

Copy link
Member Author

Choose a reason for hiding this comment

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

This would happen after another migration that would have migrated users to react-webpack5

if (frameworkPackageName !== '@storybook/react-webpack5') {
return null;
}

const addonOptions = getNextjsAddonOptions(main.getFieldValue(['addons']));
const frameworkOptions = main.getFieldValue(['framework', 'options']) || {};

const addonsToRemove = ['storybook-addon-next', 'storybook-addon-next-router'].filter(
(dep) => allDeps[dep]
);

return {
main,
addonsToRemove,
frameworkOptions: {
...frameworkOptions,
...addonOptions,
},
packageJson,
};
},

prompt({ addonsToRemove }) {
let addonsMessage = '';

if (addonsToRemove.length > 0) {
addonsMessage = `
This package also supports features provided by the following packages, which can now be removed:
${addonsToRemove.map((dep) => `- ${chalk.cyan(dep)}`).join(', ')}
`;
}

return dedent`
We've detected you are using Storybook in a ${chalk.bold('Next.js')} project.

In Storybook 7, we introduced a new framework package for Next.js projects: ${chalk.magenta(
'@storybook/nextjs'
)}.

This package is a replacement for ${chalk.magenta(
'@storybook/react-webpack5'
)} and provides a better experience for Next.js users.
${addonsMessage}
To learn more about it, see: ${chalk.yellow(
'https://github.com/storybookjs/storybook/blob/next/code/frameworks/nextjs/README.md'
)}
`;
},

async run({
result: { addonsToRemove, main, frameworkOptions, packageJson },
packageManager,
dryRun,
}) {
const dependenciesToRemove = [...addonsToRemove, '@storybook/react-webpack5'];
if (dependenciesToRemove.length > 0) {
logger.info(`✅ Removing redundant packages: ${dependenciesToRemove.join(', ')}`);
if (!dryRun) {
packageManager.removeDependencies({ skipInstall: true, packageJson }, dependenciesToRemove);

const existingAddons = main.getFieldValue(['addons']) as Addon[];
const updatedAddons = existingAddons.filter((addon) => {
if (typeof addon === 'string') {
return !addonsToRemove.includes(addon);
}

if (addon.name) {
return !addonsToRemove.includes(addon.name);
}

return false;
});
main.setFieldValue(['addons'], updatedAddons);
}
}

logger.info(`✅ Installing new dependencies: @storybook/nextjs`);
if (!dryRun) {
const versionToInstall = getStorybookVersionSpecifier(packageJson);
packageManager.addDependencies({ installAsDevDependencies: true, packageJson }, [
`@storybook/nextjs@${versionToInstall}`,
]);
}

logger.info(`✅ Updating framework field in main.js`);
if (!dryRun) {
main.setFieldValue(['framework', 'options'], frameworkOptions);
main.setFieldValue(['framework', 'name'], '@storybook/nextjs');

await writeConfig(main);
}
},
};