Skip to content

Commit

Permalink
Merge pull request #27050 from storybookjs/yann/nextjs-redirect-boundary
Browse files Browse the repository at this point in the history
Nextjs: Implement next redirect and the RedirectBoundary
  • Loading branch information
yannbf authored May 7, 2024
2 parents 872630f + a1ca72d commit 27dc023
Show file tree
Hide file tree
Showing 16 changed files with 456 additions and 1,285 deletions.
6 changes: 6 additions & 0 deletions code/frameworks/nextjs/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@
"react/no-unknown-property": "off",
"jsx-a11y/anchor-is-valid": "off"
}
},
{
"files": ["**/*.compat.@(tsx|ts)"],
"rules": {
"local-rules/no-uncategorized-errors": "off"
}
}
]
}
12 changes: 12 additions & 0 deletions code/frameworks/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@
"require": "./dist/next-image-loader-stub.js",
"import": "./dist/next-image-loader-stub.mjs"
},
"./dist/compatibility/segment.compat": {
"types": "./dist/compatibility/segment.compat.d.ts",
"require": "./dist/compatibility/segment.compat.js",
"import": "./dist/compatibility/segment.compat.mjs"
},
"./dist/compatibility/redirect-status-code.compat": {
"types": "./dist/compatibility/redirect-status-code.compat.d.ts",
"require": "./dist/compatibility/redirect-status-code.compat.js",
"import": "./dist/compatibility/redirect-status-code.compat.mjs"
},
"./export-mocks": {
"types": "./dist/export-mocks/index.d.ts",
"require": "./dist/export-mocks/index.js",
Expand Down Expand Up @@ -205,6 +215,8 @@
"./src/export-mocks/headers/index.ts",
"./src/export-mocks/router/index.ts",
"./src/export-mocks/navigation/index.ts",
"./src/compatibility/segment.compat.ts",
"./src/compatibility/redirect-status-code.compat.ts",
"./src/next-image-loader-stub.ts",
"./src/images/decorator.tsx",
"./src/images/next-legacy-image.tsx",
Expand Down
32 changes: 32 additions & 0 deletions code/frameworks/nextjs/src/compatibility/compatibility-map.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Configuration as WebpackConfig } from 'webpack';
import semver from 'semver';
import { getNextjsVersion, addScopedAlias } from '../utils';

const mapping: Record<string, Record<string, string>> = {
'<14.0.0': {
'next/dist/shared/lib/segment': '@storybook/nextjs/dist/compatibility/segment.compat',
'next/dist/client/components/redirect-status-code':
'@storybook/nextjs/dist/compatibility/redirect-status-code.compat',
},
};

export const getCompatibilityAliases = () => {
const version = getNextjsVersion();
const result: Record<string, string> = {};

Object.keys(mapping).filter((key) => {
if (semver.intersects(version, key)) {
Object.assign(result, mapping[key]);
}
});

return result;
};

export const configureCompatibilityAliases = (baseConfig: WebpackConfig): void => {
const aliases = getCompatibilityAliases();

Object.entries(aliases).forEach(([name, alias]) => {
addScopedAlias(baseConfig, name, alias);
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Compatibility for Next 13
export enum RedirectStatusCode {
SeeOther = 303,
TemporaryRedirect = 307,
PermanentRedirect = 308,
}
8 changes: 8 additions & 0 deletions code/frameworks/nextjs/src/compatibility/segment.compat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Compatibility for Next 13
// from https://github.com/vercel/next.js/blob/606f9ff7903b58da51aa043bfe71cd7b6ea306fd/packages/next/src/shared/lib/segment.ts#L4
export function isGroupSegment(segment: string) {
return segment[0] === '(' && segment.endsWith(')');
}

export const PAGE_SEGMENT_KEY = '__PAGE__';
export const DEFAULT_SEGMENT_KEY = '__DEFAULT__';
39 changes: 22 additions & 17 deletions code/frameworks/nextjs/src/export-mocks/navigation/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { Mock } from '@storybook/test';
import { fn } from '@storybook/test';
import * as actual from 'next/dist/client/components/navigation';
import { NextjsRouterMocksNotAvailable } from '@storybook/core-events/preview-errors';
import * as originalNavigation from 'next/dist/client/components/navigation';
import { RedirectStatusCode } from 'next/dist/client/components/redirect-status-code';
import { getRedirectError } from 'next/dist/client/components/redirect';

let navigationAPI: {
push: Mock;
Expand Down Expand Up @@ -56,34 +58,37 @@ export const getRouter = () => {
export * from 'next/dist/client/components/navigation';

// mock utilities/overrides (as of Next v14.2.0)
export const redirect = fn().mockName('next/navigation::redirect');
export const redirect = fn(
(url: string, type: actual.RedirectType = actual.RedirectType.push): never => {
throw getRedirectError(url, type, RedirectStatusCode.SeeOther);
}
).mockName('next/navigation::redirect');

export const permanentRedirect = fn(
(url: string, type: actual.RedirectType = actual.RedirectType.push): never => {
throw getRedirectError(url, type, RedirectStatusCode.SeeOther);
}
).mockName('next/navigation::permanentRedirect');

// passthrough mocks - keep original implementation but allow for spying
export const useSearchParams = fn(originalNavigation.useSearchParams).mockName(
export const useSearchParams = fn(actual.useSearchParams).mockName(
'next/navigation::useSearchParams'
);
export const usePathname = fn(originalNavigation.usePathname).mockName(
'next/navigation::usePathname'
);
export const useSelectedLayoutSegment = fn(originalNavigation.useSelectedLayoutSegment).mockName(
export const usePathname = fn(actual.usePathname).mockName('next/navigation::usePathname');
export const useSelectedLayoutSegment = fn(actual.useSelectedLayoutSegment).mockName(
'next/navigation::useSelectedLayoutSegment'
);
export const useSelectedLayoutSegments = fn(originalNavigation.useSelectedLayoutSegments).mockName(
export const useSelectedLayoutSegments = fn(actual.useSelectedLayoutSegments).mockName(
'next/navigation::useSelectedLayoutSegments'
);
export const useRouter = fn(originalNavigation.useRouter).mockName('next/navigation::useRouter');
export const useServerInsertedHTML = fn(originalNavigation.useServerInsertedHTML).mockName(
export const useRouter = fn(actual.useRouter).mockName('next/navigation::useRouter');
export const useServerInsertedHTML = fn(actual.useServerInsertedHTML).mockName(
'next/navigation::useServerInsertedHTML'
);
export const notFound = fn(originalNavigation.notFound).mockName('next/navigation::notFound');
export const permanentRedirect = fn(originalNavigation.permanentRedirect).mockName(
'next/navigation::permanentRedirect'
);
export const notFound = fn(actual.notFound).mockName('next/navigation::notFound');

// Params, not exported by Next.js, is manually declared to avoid inference issues.
interface Params {
[key: string]: string | string[];
}
export const useParams = fn<[], Params>(originalNavigation.useParams).mockName(
'next/navigation::useParams'
);
export const useParams = fn<[], Params>(actual.useParams).mockName('next/navigation::useParams');
43 changes: 26 additions & 17 deletions code/frameworks/nextjs/src/export-mocks/webpack.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,38 @@
import { dirname, join } from 'path';
import type { Configuration as WebpackConfig } from 'webpack';
import { getCompatibilityAliases } from '../compatibility/compatibility-map';

const mapping = {
'next/headers': '/dist/export-mocks/headers/index',
'@storybook/nextjs/headers.mock': '/dist/export-mocks/headers/index',
'next/navigation': '/dist/export-mocks/navigation/index',
'@storybook/nextjs/navigation.mock': '/dist/export-mocks/navigation/index',
'next/router': '/dist/export-mocks/router/index',
'@storybook/nextjs/router.mock': '/dist/export-mocks/router/index',
'next/cache': '/dist/export-mocks/cache/index',
'@storybook/nextjs/cache.mock': '/dist/export-mocks/cache/index',
...getCompatibilityAliases(),
};

// Utility that assists in adding aliases to the Webpack configuration
// and also doubles as alias solution for portable stories in Jest/Vitest/etc.
export const getPackageAliases = ({ useESM = false }: { useESM?: boolean } = {}) => {
const extension = useESM ? 'mjs' : 'js';
const packageLocation = dirname(require.resolve('@storybook/nextjs/package.json'));
// Use paths for both next/xyz and @storybook/nextjs/xyz imports
// to make sure they all serve the MJS version of the file
const headersPath = join(packageLocation, `/dist/export-mocks/headers/index.${extension}`);
const navigationPath = join(packageLocation, `/dist/export-mocks/navigation/index.${extension}`);
const cachePath = join(packageLocation, `/dist/export-mocks/cache/index.${extension}`);
const routerPath = join(packageLocation, `/dist/export-mocks/router/index.${extension}`);

return {
'next/headers': headersPath,
'@storybook/nextjs/headers.mock': headersPath,
const getFullPath = (path: string) =>
join(packageLocation, path.replace('@storybook/nextjs', ''));

'next/navigation': navigationPath,
'@storybook/nextjs/navigation.mock': navigationPath,
const aliases = Object.fromEntries(
Object.entries(mapping).map(([originalPath, aliasedPath]) => [
originalPath,
// Use paths for both next/xyz and @storybook/nextjs/xyz imports
// to make sure they all serve the MJS/CJS version of the file
getFullPath(`${aliasedPath}.${extension}`),
])
);

'next/router': routerPath,
'@storybook/nextjs/router.mock': routerPath,

'next/cache': cachePath,
'@storybook/nextjs/cache.mock': cachePath,
};
return aliases;
};

export const configureNextExportMocks = (baseConfig: WebpackConfig): void => {
Expand Down
9 changes: 4 additions & 5 deletions code/frameworks/nextjs/src/fastRefresh/webpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@ import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin';
export const configureFastRefresh = (baseConfig: WebpackConfig): void => {
baseConfig.plugins = [
...(baseConfig.plugins ?? []),
new ReactRefreshWebpackPlugin({
overlay: {
sockIntegration: 'whm',
},
}),
// overlay is disabled as it is shown with caught errors in error boundaries
// and the next app router is using error boundaries to redirect
// TODO use the Next error overlay
new ReactRefreshWebpackPlugin({ overlay: false }),
];
};
2 changes: 2 additions & 0 deletions code/frameworks/nextjs/src/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { configureFastRefresh } from './fastRefresh/webpack';
import { configureAliases } from './aliases/webpack';
import { logger } from '@storybook/node-logger';
import { configureNextExportMocks } from './export-mocks/webpack';
import { configureCompatibilityAliases } from './compatibility/compatibility-map';

export const addons: PresetProperty<'addons'> = [
dirname(require.resolve(join('@storybook/preset-react-webpack', 'package.json'))),
Expand Down Expand Up @@ -135,6 +136,7 @@ export const webpackFinal: StorybookConfig['webpackFinal'] = async (baseConfig,
configureStyledJsx(baseConfig);
configureNodePolyfills(baseConfig);
configureAliases(baseConfig);
configureCompatibilityAliases(baseConfig);
configureNextExportMocks(baseConfig);

if (isDevelopment) {
Expand Down
34 changes: 34 additions & 0 deletions code/frameworks/nextjs/src/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { createRouter } from '@storybook/nextjs/router.mock';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore we must ignore types here as during compilation they are not generated yet
import { createNavigation } from '@storybook/nextjs/navigation.mock';
import { isNextRouterError } from 'next/dist/client/components/is-next-router-error';

function addNextHeadCount() {
const meta = document.createElement('meta');
Expand All @@ -25,8 +26,33 @@ function addNextHeadCount() {
document.head.appendChild(meta);
}

function isAsyncClientComponentError(error: unknown) {
return (
typeof error === 'string' &&
(error.includes('A component was suspended by an uncached promise.') ||
error.includes('async/await is not yet supported in Client Components'))
);
}
addNextHeadCount();

// Copying Next patch of console.error:
// https://github.com/vercel/next.js/blob/a74deb63e310df473583ab6f7c1783bc609ca236/packages/next/src/client/app-index.tsx#L15
const origConsoleError = globalThis.console.error;
globalThis.console.error = (...args: unknown[]) => {
const error = args[0];
if (isNextRouterError(error) || isAsyncClientComponentError(error)) {
return;
}
origConsoleError.apply(globalThis.console, args);
};

globalThis.addEventListener('error', (ev: WindowEventMap['error']): void => {
if (isNextRouterError(ev.error) || isAsyncClientComponentError(ev.error)) {
ev.preventDefault();
return;
}
});

export const decorators: Addon_DecoratorFunction<any>[] = [
StyledJsxDecorator,
ImageDecorator,
Expand All @@ -52,4 +78,12 @@ export const parameters = {
excludeDecorators: true,
},
},
react: {
rootOptions: {
onCaughtError(error: unknown) {
if (isNextRouterError(error)) return;
console.error(error);
},
},
},
};
10 changes: 9 additions & 1 deletion code/frameworks/nextjs/src/routing/decorator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Addon_StoryContext } from '@storybook/types';
import { AppRouterProvider } from './app-router-provider';
import { PageRouterProvider } from './page-router-provider';
import type { RouteParams, NextAppDirectory } from './types';
import { RedirectBoundary } from 'next/dist/client/components/redirect-boundary';

const defaultRouterParams: RouteParams = {
pathname: '/',
Expand All @@ -27,7 +28,14 @@ export const RouterDecorator = (
...parameters.nextjs?.navigation,
}}
>
<Story />
{/*
The next.js RedirectBoundary causes flashing UI when used client side.
Possible use the implementation of the PR: https://github.com/vercel/next.js/pull/49439
Or wait for next to solve this on their side.
*/}
<RedirectBoundary>
<Story />
</RedirectBoundary>
</AppRouterProvider>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/test';
import { redirect } from 'next/navigation';

let state = 'Bug! Not invalidated';

export default {
render() {
return (
<div>
<div>{state}</div>
<form
action={() => {
state = 'State is invalidated successfully.';
redirect('/');
}}
>
<button>Submit</button>
</form>
</div>
);
},
parameters: {
test: {
// This is needed until Next will update to the React 19 beta: https://github.com/vercel/next.js/pull/65058
// In the React 19 beta ErrorBoundary errors (such as redirect) are only logged, and not thrown.
// We will also suspress console.error logs for re the console.error logs for redirect in the next framework.
// Using the onCaughtError react root option:
// react: {
// rootOptions: {
// onCaughtError(error: unknown) {
// if (isNextRouterError(error)) return;
// console.error(error);
// },
// },
// See: code/frameworks/nextjs/src/preview.tsx
dangerouslyIgnoreUnhandledErrors: true,
},
nextjs: {
appDirectory: true,
navigation: {
pathname: '/',
},
},
},
} as Meta;

export const SingletonStateGetsInvalidatedAfterRedirecting: StoryObj = {
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button'));
},
};
Loading

0 comments on commit 27dc023

Please sign in to comment.