-
-
Notifications
You must be signed in to change notification settings - Fork 9.4k
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
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
2b4e3a7
add nextjs framework automigration
yannbf d1adc1e
warn vite+next users about the nextjs package
yannbf 665724e
remove skipInstall
yannbf e134c74
Fix types
shilman 17f8133
Fix types
shilman ec2ab18
update links and improve prompt text
yannbf File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
183 changes: 183 additions & 0 deletions
183
code/lib/cli/src/automigrate/fixes/nextjs-framework.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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')); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
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); | ||
} | ||
}, | ||
}; |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 in7.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?There was a problem hiding this comment.
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