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

Next.js: Add experimental SWC support #24852

Merged
merged 20 commits into from
Nov 20, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
107 changes: 11 additions & 96 deletions code/builders/builder-webpack5/src/preview/iframe-webpack.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { dirname, isAbsolute, join, resolve } from 'path';
import { dirname, join, resolve } from 'path';
import { DefinePlugin, HotModuleReplacementPlugin, ProgressPlugin, ProvidePlugin } from 'webpack';
import type { Configuration } from 'webpack';
import HtmlWebpackPlugin from 'html-webpack-plugin';
Expand All @@ -7,25 +7,20 @@ import CaseSensitivePathsPlugin from 'case-sensitive-paths-webpack-plugin';
import TerserWebpackPlugin from 'terser-webpack-plugin';
import VirtualModulePlugin from 'webpack-virtual-modules';
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
import slash from 'slash';
import type { TransformOptions as EsbuildOptions } from 'esbuild';
import type { JsMinifyOptions as SwcOptions } from '@swc/core';
import type { Options, CoreConfig, DocsOptions, PreviewAnnotation } from '@storybook/types';
import type { Options, CoreConfig, DocsOptions } from '@storybook/types';
import { globalsNameReferenceMap } from '@storybook/preview/globals';
import {
getBuilderOptions,
getRendererName,
stringifyProcessEnvs,
handlebars,
interpolate,
normalizeStories,
readTemplate,
loadPreviewOrConfigFile,
isPreservingSymlinks,
} from '@storybook/core-common';
import { toRequireContextString, toImportFn } from '@storybook/core-webpack';
import type { BuilderOptions } from '@storybook/core-webpack';
import { getVirtualModuleMapping } from '@storybook/core-webpack';
import { dedent } from 'ts-dedent';
import type { BuilderOptions, TypescriptOptions } from '../types';
import type { TypescriptOptions } from '../types';
import { createBabelLoader, createSWCLoader } from './loaders';

const getAbsolutePath = <I extends string>(input: I): I =>
Expand Down Expand Up @@ -114,92 +109,6 @@ export default async (

const builderOptions = await getBuilderOptions<BuilderOptions>(options);

const previewAnnotations = [
...(await presets.apply<PreviewAnnotation[]>('previewAnnotations', [], options)).map(
(entry) => {
// If entry is an object, use the absolute import specifier.
// This is to maintain back-compat with community addons that bundle other addons
// and package managers that "hide" sub dependencies (e.g. pnpm / yarn pnp)
// The vite builder uses the bare import specifier.
if (typeof entry === 'object') {
return entry.absolute;
}

// TODO: Remove as soon as we drop support for disabled StoryStoreV7
if (isAbsolute(entry)) {
return entry;
}

return slash(entry);
}
),
loadPreviewOrConfigFile(options),
].filter(Boolean);

const virtualModuleMapping: Record<string, string> = {};
if (features?.storyStoreV7) {
const storiesFilename = 'storybook-stories.js';
const storiesPath = resolve(join(workingDir, storiesFilename));

const needPipelinedImport = !!builderOptions.lazyCompilation && !isProd;
virtualModuleMapping[storiesPath] = toImportFn(stories, { needPipelinedImport });
const configEntryPath = resolve(join(workingDir, 'storybook-config-entry.js'));
virtualModuleMapping[configEntryPath] = handlebars(
await readTemplate(
require.resolve(
'@storybook/builder-webpack5/templates/virtualModuleModernEntry.js.handlebars'
)
),
{
storiesFilename,
previewAnnotations,
}
// We need to double escape `\` for webpack. We may have some in windows paths
).replace(/\\/g, '\\\\');
entries.push(configEntryPath);
} else {
const rendererName = await getRendererName(options);

const rendererInitEntry = resolve(join(workingDir, 'storybook-init-renderer-entry.js'));
virtualModuleMapping[rendererInitEntry] = `import '${slash(rendererName)}';`;
entries.push(rendererInitEntry);

const entryTemplate = await readTemplate(
join(__dirname, '..', '..', 'templates', 'virtualModuleEntry.template.js')
);

previewAnnotations.forEach((previewAnnotationFilename: string | undefined) => {
if (!previewAnnotationFilename) return;

// Ensure that relative paths end up mapped to a filename in the cwd, so a later import
// of the `previewAnnotationFilename` in the template works.
const entryFilename = previewAnnotationFilename.startsWith('.')
? `${previewAnnotationFilename.replace(/(\w)(\/|\\)/g, '$1-')}-generated-config-entry.js`
: `${previewAnnotationFilename}-generated-config-entry.js`;
// NOTE: although this file is also from the `dist/cjs` directory, it is actually a ESM
// file, see https://github.com/storybookjs/storybook/pull/16727#issuecomment-986485173
virtualModuleMapping[entryFilename] = interpolate(entryTemplate, {
previewAnnotationFilename,
});
entries.push(entryFilename);
});
if (stories.length > 0) {
const storyTemplate = await readTemplate(
join(__dirname, '..', '..', 'templates', 'virtualModuleStory.template.js')
);
// NOTE: this file has a `.cjs` extension as it is a CJS file (from `dist/cjs`) and runs
// in the user's webpack mode, which may be strict about the use of require/import.
// See https://github.com/storybookjs/storybook/issues/14877
const storiesFilename = resolve(join(workingDir, `generated-stories-entry.cjs`));
virtualModuleMapping[storiesFilename] = interpolate(storyTemplate, {
rendererName,
})
// Make sure we also replace quotes for this one
.replace("'{{stories}}'", stories.map(toRequireContextString).join(','));
entries.push(storiesFilename);
}
}

const shouldCheckTs =
typescriptOptions.check && !typescriptOptions.skipBabel && !typescriptOptions.skipCompiler;
const tsCheckOptions = typescriptOptions.checkOptions || {};
Expand All @@ -226,6 +135,12 @@ export default async (
externals['@storybook/blocks'] = '__STORYBOOK_BLOCKS_EMPTY_MODULE__';
}

const virtualModuleMapping = await getVirtualModuleMapping(options);

Object.keys(virtualModuleMapping).forEach((key) => {
entries.push(key);
});

return {
name: 'preview',
mode: isProd ? 'production' : 'development',
Expand Down
13 changes: 4 additions & 9 deletions code/builders/builder-webpack5/src/preview/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const createBabelLoader = (
typescriptOptions: TypescriptOptions,
excludes: string[] = []
) => {
logger.info(dedent`Using Babel compiler`);
return {
test: typescriptOptions.skipBabel ? /\.(mjs|jsx?)$/ : /\.(mjs|tsx?|jsx?)$/,
use: [
Expand All @@ -24,9 +25,7 @@ export const createBabelLoader = (
};

export const createSWCLoader = async (excludes: string[] = [], options: Options) => {
logger.warn(dedent`
The SWC loader is an experimental feature and may change or even be removed at any time.
`);
logger.info(dedent`Using SWC compiler`);

const swc = await options.presets.apply('swc', {}, options);
const typescriptOptions = await options.presets.apply<{ skipCompiler?: boolean }>(
Expand All @@ -49,12 +48,8 @@ export const createSWCLoader = async (excludes: string[] = [], options: Options)
};
return {
test: typescriptOptions.skipCompiler ? /\.(mjs|cjs|jsx?)$/ : /\.(mjs|cjs|tsx?|jsx?)$/,
use: [
{
loader: require.resolve('swc-loader'),
options: config,
},
],
loader: require.resolve('swc-loader'),
options: config,
include: [getProjectRoot()],
exclude: [/node_modules/, ...excludes],
};
Expand Down
20 changes: 12 additions & 8 deletions code/e2e-tests/addon-controls.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,22 @@ test.describe('addon-controls', () => {
);
const toggle = sbPage.panelContent().locator('input[name=primary]');
await toggle.click();
await expect(sbPage.previewRoot().locator('button')).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)'
);
await expect(async () => {
await expect(sbPage.previewRoot().locator('button')).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)'
);
}).toPass();

// Color picker: Background color
const color = sbPage.panelContent().locator('input[placeholder="Choose color..."]');
await color.fill('red');
await expect(sbPage.previewRoot().locator('button')).toHaveCSS(
'background-color',
'rgb(255, 0, 0)'
);
await expect(async () => {
await expect(sbPage.previewRoot().locator('button')).toHaveCSS(
'background-color',
'rgb(255, 0, 0)'
);
}).toPass();

// TODO: enable this once the controls for size are aligned in all CLI templates.
// Radio buttons: Size
Expand Down
7 changes: 6 additions & 1 deletion code/frameworks/nextjs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,12 @@ export default {
framework: {
// name: '@storybook/react-webpack5', // Remove this
name: '@storybook/nextjs', // Add this
options: {},
options: {
builder: {
// Set useSWC to true if you want to try out the experimental SWC compiler in Next.js >= 14.0.0
useSWC: true,
},
},
},
};
```
Expand Down
12 changes: 10 additions & 2 deletions code/frameworks/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@
"types": "./dist/preset.d.ts",
"require": "./dist/preset.js"
},
"./font/webpack/loader/storybook-nextjs-font-loader": {
"types": "./dist/font/webpack/loader/storybook-nextjs-font-loader.d.ts",
"require": "./dist/font/webpack/loader/storybook-nextjs-font-loader.js",
"import": "./dist/font/webpack/loader/storybook-nextjs-font-loader.mjs"
},
"./dist/preview.mjs": "./dist/preview.mjs",
"./next-image-loader-stub.js": {
"types": "./dist/next-image-loader-stub.d.ts",
Expand Down Expand Up @@ -83,10 +88,12 @@
"@babel/preset-react": "^7.22.15",
"@babel/preset-typescript": "^7.23.2",
"@babel/runtime": "^7.23.2",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.11",
"@storybook/addon-actions": "workspace:*",
"@storybook/builder-webpack5": "workspace:*",
"@storybook/core-common": "workspace:*",
"@storybook/core-events": "workspace:*",
"@storybook/core-webpack": "workspace:*",
"@storybook/node-logger": "workspace:*",
"@storybook/preset-react-webpack": "workspace:*",
"@storybook/preview-api": "workspace:*",
Expand Down Expand Up @@ -117,7 +124,7 @@
"@types/babel__plugin-transform-runtime": "^7",
"@types/babel__preset-env": "^7",
"@types/loader-utils": "^2.0.5",
"next": "^14.0.0",
"next": "^14.0.2",
"typescript": "^4.9.3",
"webpack": "^5.65.0"
},
Expand Down Expand Up @@ -156,7 +163,8 @@
"./src/images/next-future-image.tsx",
"./src/images/next-legacy-image.tsx",
"./src/images/next-image.tsx",
"./src/font/webpack/loader/storybook-nextjs-font-loader.ts"
"./src/font/webpack/loader/storybook-nextjs-font-loader.ts",
"./src/swc/next-swc-loader-patch.ts"
],
"externals": [
"sb-original/next/image",
Expand Down
3 changes: 3 additions & 0 deletions code/frameworks/nextjs/src/css/webpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export const configureCss = (baseConfig: WebpackConfig, nextConfig: NextConfig):
},
require.resolve('postcss-loader'),
],
// We transform the "target.css" files from next.js into Javascript
// for Next.js to support fonts, so it should be ignored by the css-loader.
exclude: /next\/.*\/target.css$/,
};
}
});
Expand Down
30 changes: 19 additions & 11 deletions code/frameworks/nextjs/src/font/webpack/configureNextFont.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import type { Configuration } from 'webpack';

export function configureNextFont(baseConfig: Configuration) {
baseConfig.plugins = [...(baseConfig.plugins || [])];
baseConfig.resolveLoader = {
...baseConfig.resolveLoader,
alias: {
...baseConfig.resolveLoader?.alias,
'storybook-nextjs-font-loader': require.resolve(
'./font/webpack/loader/storybook-nextjs-font-loader'
),
},
};
export function configureNextFont(baseConfig: Configuration, isSWC?: boolean) {
const fontLoaderPath = require.resolve(
'@storybook/nextjs/font/webpack/loader/storybook-nextjs-font-loader'
);

if (isSWC) {
baseConfig.module?.rules?.push({
test: /next\/.*\/target.css$/,
loader: fontLoaderPath,
});
} else {
baseConfig.resolveLoader = {
...baseConfig.resolveLoader,
alias: {
...baseConfig.resolveLoader?.alias,
'storybook-nextjs-font-loader': fontLoaderPath,
},
};
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
import loaderUtils from 'next/dist/compiled/loader-utils3';
import { getProjectRoot } from '@storybook/core-common';
import path from 'path';

import type { LoaderOptions } from '../types';
Expand All @@ -11,7 +12,9 @@ export async function getFontFaceDeclarations(options: LoaderOptions, rootContex
const localFontSrc = options.props.src as LocalFontSrc;

// Parent folder relative to the root context
const parentFolder = path.dirname(options.filename).replace(rootContext, '');
const parentFolder = path
.dirname(path.join(getProjectRoot(), options.filename))
.replace(rootContext, '');

const { validateData } = require('../utils/local-font-utils');
const { weight, style, variable } = validateData('', options.props);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,34 @@ type FontFaceDeclaration = {
};

export default async function storybookNextjsFontLoader(this: any) {
const options = this.getOptions() as LoaderOptions;
const loaderOptions = this.getOptions() as LoaderOptions;
let options;

if (Object.keys(loaderOptions).length > 0) {
// handles Babel mode
options = loaderOptions;
} else {
// handles SWC mode
const importQuery = JSON.parse(this.resourceQuery.slice(1));

options = {
filename: importQuery.path,
fontFamily: importQuery.import,
props: importQuery.arguments[0],
source: this.context.replace(this.rootContext, ''),
};
}

// get execution context
const rootCtx = this.rootContext;

let fontFaceDeclaration: FontFaceDeclaration | undefined;

if (options.source === 'next/font/google' || options.source === '@next/font/google') {
if (options.source.endsWith('next/font/google') || options.source.endsWith('@next/font/google')) {
fontFaceDeclaration = await getGoogleFontFaceDeclarations(options);
}

if (options.source === 'next/font/local' || options.source === '@next/font/local') {
if (options.source.endsWith('next/font/local') || options.source.endsWith('@next/font/local')) {
fontFaceDeclaration = await getLocalFontFaceDeclarations(options, rootCtx);
}

Expand Down