Skip to content

Commit

Permalink
Merge pull request #24852 from storybookjs/valentin/add-swc-support-f…
Browse files Browse the repository at this point in the history
…or-nextjs

Next.js: Add experimental SWC support
  • Loading branch information
valentinpalkovic committed Nov 20, 2023
2 parents b15a996 + 1c3105a commit 28cc7c3
Show file tree
Hide file tree
Showing 35 changed files with 617 additions and 214 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
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

0 comments on commit 28cc7c3

Please sign in to comment.