Skip to content

Commit

Permalink
Next.js: Add experimental SWC support
Browse files Browse the repository at this point in the history
  • Loading branch information
valentinpalkovic committed Nov 15, 2023
1 parent 1ecc591 commit 554ad41
Show file tree
Hide file tree
Showing 14 changed files with 474 additions and 160 deletions.
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
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
16 changes: 7 additions & 9 deletions code/frameworks/nextjs/src/font/webpack/configureNextFont.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,11 @@ 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'
),
},
};

const fontLoaderPath = require.resolve('./font/webpack/loader/storybook-nextjs-font-loader');

baseConfig.module?.rules?.push({
test: /next\/.*\/target.css$/,
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,31 @@ type FontFaceDeclaration = {
};

export default async function storybookNextjsFontLoader(this: any) {
const options = this.getOptions() as LoaderOptions;
const importQuery = JSON.parse(this.resourceQuery.slice(1));
const loaderOptions = this.getOptions() as LoaderOptions;
let options;

if (Object.keys(loaderOptions).length > 0) {
options = loaderOptions;
} else {
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
12 changes: 10 additions & 2 deletions code/frameworks/nextjs/src/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { configureNextFont } from './font/webpack/configureNextFont';
import nextBabelPreset from './babel/preset';
import { configureNodePolyfills } from './nodePolyfills/webpack';
import { configureAliasing } from './dependency-map';
import { configureSWCLoader } from './swc/loader';

export const addons: PresetProperty<'addons', StorybookConfig> = [
dirname(require.resolve(join('@storybook/preset-react-webpack', 'package.json'))),
Expand Down Expand Up @@ -61,7 +62,9 @@ export const core: PresetProperty<'core', StorybookConfig> = async (config, opti
name: dirname(
require.resolve(join('@storybook/builder-webpack5', 'package.json'))
) as '@storybook/builder-webpack5',
options: typeof framework === 'string' ? {} : framework.options.builder || {},
options: {
...(typeof framework === 'string' ? {} : framework.options.builder || {}),
},
},
renderer: dirname(require.resolve(join('@storybook/react', 'package.json'))),
};
Expand Down Expand Up @@ -135,7 +138,7 @@ export const webpackFinal: StorybookConfig['webpackFinal'] = async (baseConfig,
const frameworkOptions = await options.presets.apply<{ options: FrameworkOptions }>(
'frameworkOptions'
);
const { options: { nextConfigPath } = {} } = frameworkOptions;
const { options: { nextConfigPath, builder } = {} } = frameworkOptions;
const nextConfig = await configureConfig({
baseConfig,
nextConfigPath,
Expand All @@ -152,5 +155,10 @@ export const webpackFinal: StorybookConfig['webpackFinal'] = async (baseConfig,
configureStyledJsx(baseConfig);
configureNodePolyfills(baseConfig);

// TODO: In Storybook 8.0, we have to check whether the babel-compiler addon is used. Otherwise, swc should be used.
if (builder?.useSWC) {
await configureSWCLoader(baseConfig, options, nextConfig);
}

return baseConfig;
};
60 changes: 60 additions & 0 deletions code/frameworks/nextjs/src/swc/loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { getProjectRoot } from '@storybook/core-common';
import { getVirtualModuleMapping } from '@storybook/core-webpack';
import type { Options } from '@storybook/types';
import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin';
import type { NextConfig } from 'next';
import { getSupportedBrowsers } from 'next/dist/build/utils';
import path from 'path';
import type { RuleSetRule } from 'webpack';

export const configureSWCLoader = async (
baseConfig: any,
options: Options,
nextConfig: NextConfig
) => {
const isDevelopment = options.configType !== 'PRODUCTION';

const dir = getProjectRoot();

baseConfig.plugins = [
...baseConfig.plugins,
new ReactRefreshWebpackPlugin({
overlay: {
sockIntegration: 'whm',
},
}),
];

const virtualModules = await getVirtualModuleMapping(options);

baseConfig.module.rules = [
// TODO: Remove filtering in Storybook 8.0
...baseConfig.module.rules.filter(
(r: RuleSetRule) =>
!(typeof r.use === 'object' && 'loader' in r.use && r.use.loader?.includes('swc-loader'))
),
{
test: /\.(m?(j|t)sx?)$/,
include: [getProjectRoot()],
exclude: [/(node_modules)/, ...Object.keys(virtualModules)],
use: {
// we use our own patch because we need to remove tracing from the original code
// which is not possible otherwise
loader: require.resolve('./swc/next-swc-loader-patch.js'),
options: {
isServer: false,
rootDir: dir,
pagesDir: `${dir}/pages`,
appDir: `${dir}/apps`,
hasReactRefresh: isDevelopment,
hasServerComponents: true,
nextConfig,
supportedBrowsers: getSupportedBrowsers(dir, isDevelopment),
swcCacheDir: path.join(dir, nextConfig?.distDir ?? '.next', 'cache', 'swc'),
isServerLayer: false,
bundleTarget: 'default',
},
},
},
];
};

0 comments on commit 554ad41

Please sign in to comment.