Support next/navigation in Next.js v13

This commit is contained in:
Valentin Palkovic 2022-12-02 13:49:06 +01:00
parent d4d0e81ab1
commit 240776b7b5
11 changed files with 556 additions and 81 deletions

View File

@ -14,6 +14,7 @@
- [Remote Images](#remote-images)
- [Optimization](#optimization)
- [AVIF](#avif)
- [Next.js Navigation](#nextjs-navigation)
- [Next.js Routing](#nextjs-routing)
- [Overriding defaults](#overriding-defaults)
- [Global Defaults](#global-defaults)
@ -38,7 +39,9 @@
👉 [Next.js's Image Component](#nextjss-image-component)
👉 [Next.js Routing](#nextjs-routing)
👉 [Next.js Routing (next/router)](#nextjs-routing)
👉 [Next.js Navigation (next/navigation)](#nextjs-navigation)
👉 [Sass/Scss](#sassscss)
@ -58,7 +61,7 @@
## Requirements
- [Next.js](https://nextjs.org/) >= 9.x
- [Next.js](https://nextjs.org/) >= 12.x
- [Storybook](https://storybook.js.org/) >= 7.x
## Getting Started
@ -167,22 +170,137 @@ export default function Home() {
}
```
#### Optimization
All Next.js `Image`s are automatically [unoptimized](https://nextjs.org/docs/api-reference/next/image#unoptimized) for you.
If [placeholder="blur"](https://nextjs.org/docs/api-reference/next/image#placeholder) is used, the [blurDataURL](https://nextjs.org/docs/api-reference/next/image#blurdataurl) used is the [src](https://nextjs.org/docs/api-reference/next/image#src) of the image (thus effectively disabling the placeholder).
See [this issue](https://github.com/vercel/next.js/issues/18393) for more discussion on how Next.js `Image`s are handled for Storybook.
#### AVIF
This format is not supported by this framework yet. Feel free to [open up an issue](https://github.com/storybookjs/storybook/issues) if this is something you want to see.
### Next.js Navigation
Please consider, that [next/navigation](https://beta.nextjs.org/docs/upgrade-guide#step-5-migrating-routing-hooks) can only be used in components/pages of the `app` directory of Next.js v13 or higher.
#### Set `nextAppDirectory` to `app`
If your story imports components, which uses `next/navigation`, you need to set the parameter `nextAppDirectory` to `app` in your Story:
```js
// SomeComponentThatUsesTheRouter.stories.js
import SomeComponentThatUsesTheNavigation from './SomeComponentThatUsesTheNavigation';
export default {
component: SomeComponentThatUsesTheNavigation,
};
// if you have the actions addon
// you can click the links and see the route change events there
export const Example = {
parameters: {
nextAppDirectory: true,
},
},
```
If your Next.js project doesn't have a `pages` directory, you can set the parameter `nextAppDirectory` to `true` in the [preview.js](https://storybook.js.org/docs/react/configure/overview#configure-story-rendering) file. Then the parameter is not needed in the stories.
```js
// .storybook/preview.js
export const parameters = {
nextAppDirectory: true,
};
```
The parameter `nextAppDirectory` defaults to `false` if not set.
#### Default Navigation Context
The default values on the stubbed navigation context are as follows (see [globals](https://storybook.js.org/docs/react/essentials/toolbars-and-globals#globals) for more details on how globals work)
```ts
const defaultNavigationContext = {
push(...args) {
action('nextRouter.push')(...args);
},
replace(...args) {
action('nextRouter.replace')(...args);
},
forward(...args) {
action('nextRouter.forward')(...args);
},
back(...args) {
action('nextRouter.back')(...args);
},
prefetch(...args) {
action('nextRouter.prefetch')(...args);
},
refresh: () => {
action('nextRouter.refresh')();
},
pathname: '/',
query: {},
};
```
#### Global Defaults
Global defaults can be set in [preview.js](https://storybook.js.org/docs/react/configure/overview#configure-story-rendering) and will be shallowly merged with the default router.
```js
// .storybook/preview.js
export const parameters = {
nextAppDirectory: true,
nextNavigation: {
pathname: '/some-default-path',
query: {
foo: 'bar',
},
},
};
```
#### Actions Integration Caveats
If you override a function, you lose the automatic action tab integration and have to build it out yourself.
```js
// .storybook/preview.js
export const parameters = {
nextAppDirectory: true,
nextNavigation: {
push() {
// The default implementation that logs the action into the action tab is lost
},
},
};
```
Doing this yourself looks something like this (make sure you install the `@storybook/addon-actions` package):
```js
// .storybook/preview.js
import { action } from '@storybook/addon-actions';
export const parameters = {
nextAppDirectory: true,
nextNavigation: {
push(...args) {
// custom logic can go here
// this logs to the actions tab
action('nextRouter.push')(...args);
// return whatever you want here
return Promise.resolve(true);
},
},
};
```
### Next.js Routing
[Next.js's router](https://nextjs.org/docs/routing/introduction) is automatically stubbed for you so that when the router is interacted with, all of its interactions are automatically logged to the [Storybook actions tab](https://storybook.js.org/docs/react/essentials/actions) if you have the actions addon.
You should only use `next/router` in the `pages` directory of Next.js v13 or higher. In the `app` directory it is necessary to use `next/navigation`.
#### Overriding defaults
Per-story overrides can be done by adding a `nextRouter` property onto the story [parameters](https://storybook.js.org/docs/react/writing-stories/parameters). The framework will shallowly merge whatever you put here into the router.
@ -215,7 +333,7 @@ export const Example = {
Global defaults can be set in [preview.js](https://storybook.js.org/docs/react/configure/overview#configure-story-rendering) and will be shallowly merged with the default router.
```js
// .storybook/main.js
// .storybook/preview.js
export const parameters = {
nextRouter: {
@ -232,44 +350,51 @@ The default values on the stubbed router are as follows (see [globals](https://s
```ts
const defaultRouter = {
locale: context?.globals?.locale,
route: '/',
pathname: '/',
query: {},
asPath: '/',
push(...args: unknown[]) {
push(...args) {
action('nextRouter.push')(...args);
return Promise.resolve(true);
},
replace(...args: unknown[]) {
replace(...args) {
action('nextRouter.replace')(...args);
return Promise.resolve(true);
},
reload(...args: unknown[]) {
reload(...args) {
action('nextRouter.reload')(...args);
},
back(...args: unknown[]) {
back(...args) {
action('nextRouter.back')(...args);
},
prefetch(...args: unknown[]) {
forward() {
action('nextRouter.forward')();
},
prefetch(...args) {
action('nextRouter.prefetch')(...args);
return Promise.resolve();
},
beforePopState(...args: unknown[]) {
beforePopState(...args) {
action('nextRouter.beforePopState')(...args);
},
events: {
on(...args: unknown[]) {
on(...args) {
action('nextRouter.events.on')(...args);
},
off(...args: unknown[]) {
off(...args) {
action('nextRouter.events.off')(...args);
},
emit(...args: unknown[]) {
emit(...args) {
action('nextRouter.events.emit')(...args);
},
},
locale: globals?.locale,
asPath: '/',
basePath: '/',
isFallback: false,
isLocaleDomain: false,
isReady: true,
isPreview: false,
route: '/',
pathname: '/',
query: {},
};
```
@ -278,7 +403,7 @@ const defaultRouter = {
If you override a function, you lose the automatic action tab integration and have to build it out yourself.
```js
// .storybook/main.js
// .storybook/preview.js
export const parameters = {
nextRouter: {
@ -292,7 +417,7 @@ export const parameters = {
Doing this yourself looks something like this (make sure you install the `@storybook/addon-actions` package):
```js
// .storybook/main.js
// .storybook/preview.js
import { action } from '@storybook/addon-actions';
export const parameters = {
@ -404,14 +529,6 @@ You can use your own babel config too. This is an example of how you can customi
}
```
If you use a monorepo, you may need to add the babel config yourself to your storybook project. Just add a babel config to your storybook project with the following contents to get started.
```json
{
"presets": ["next/babel"]
}
```
### Postcss
Next.js lets you [customize postcss config](https://nextjs.org/docs/advanced-features/customizing-postcss-config#default-behavior). Thus this framework will automatically handle your postcss config for you.

View File

@ -8,6 +8,8 @@ export function configureNextImport(baseConfig: WebpackConfig) {
const isNext12 = semver.satisfies(nextJSVersion, '~12');
const isNext13 = semver.satisfies(nextJSVersion, '~13');
const isNextVersionSmallerThan13 = semver.lt(nextJSVersion, '13.0.0');
const isNextVersionSmallerThan12 = semver.lt(nextJSVersion, '12.0.0');
baseConfig.plugins = baseConfig.plugins ?? [];
@ -26,4 +28,20 @@ export function configureNextImport(baseConfig: WebpackConfig) {
})
);
}
if (isNextVersionSmallerThan13) {
baseConfig.plugins.push(
new IgnorePlugin({
resourceRegExp: /next\/dist\/shared\/lib\/hooks-client-context$/,
})
);
}
if (isNextVersionSmallerThan12) {
baseConfig.plugins.push(
new IgnorePlugin({
resourceRegExp: /next\/dist\/shared\/lib\/app-router-context$/,
})
);
}
}

View File

@ -0,0 +1,44 @@
import React from 'react';
import { AppRouterContext } from 'next/dist/shared/lib/app-router-context';
import { PathnameContext, SearchParamsContext } from 'next/dist/shared/lib/hooks-client-context';
import type { RouteParams } from './types';
type AppRouterProviderProps = {
action: (name: string) => (...args: any[]) => void;
routeParams: RouteParams;
};
const AppRouterProvider: React.FC<AppRouterProviderProps> = ({ children, action, routeParams }) => {
const { pathname, query, ...restRouteParams } = routeParams;
return (
<AppRouterContext.Provider
value={{
push(...args) {
action('nextRouter.push')(...args);
},
replace(...args) {
action('nextRouter.replace')(...args);
},
forward(...args) {
action('nextRouter.forward')(...args);
},
back(...args) {
action('nextRouter.back')(...args);
},
prefetch(...args) {
action('nextRouter.prefetch')(...args);
},
refresh: () => {
action('nextRouter.refresh')();
},
...restRouteParams,
}}
>
<SearchParamsContext.Provider value={new URLSearchParams(query)}>
<PathnameContext.Provider value={pathname}>{children}</PathnameContext.Provider>
</SearchParamsContext.Provider>
</AppRouterContext.Provider>
);
};
export default AppRouterProvider;

View File

@ -2,8 +2,17 @@ import * as React from 'react';
// this will be aliased by webpack at runtime (this is just for typing)
import type { action as originalAction } from '@storybook/addon-actions';
import type { Addon_StoryContext } from '@storybook/types';
import { RouterContext } from 'next/dist/shared/lib/router-context';
import Router from 'next/router';
import PageRouterProvider from './page-router-provider';
import type { RouteParams, NextAppDirectory } from './types';
/**
* Dynamic import necessary because otherwise
* older versions of Next.js will throw an error
* because some imports in './app-router-provider' only exists
* in Next.js > v13
*/
const AppRouterProvider = React.lazy(() => import('./app-router-provider'));
let action: typeof originalAction;
@ -13,61 +22,41 @@ try {
action = () => () => {};
}
const defaultRouter = {
route: '/',
const defaultRouterParams: RouteParams = {
pathname: '/',
query: {},
asPath: '/',
push(...args: unknown[]): Promise<boolean> {
action('nextRouter.push')(...args);
return Promise.resolve(true);
},
replace(...args: unknown[]): Promise<boolean> {
action('nextRouter.replace')(...args);
return Promise.resolve(true);
},
reload(...args: unknown[]): void {
action('nextRouter.reload')(...args);
},
back(...args: unknown[]): void {
action('nextRouter.back')(...args);
},
prefetch(...args: unknown[]): Promise<void> {
action('nextRouter.prefetch')(...args);
return Promise.resolve();
},
beforePopState(...args: unknown[]): void {
action('nextRouter.beforePopState')(...args);
},
events: {
on(...args: unknown[]): void {
action('nextRouter.events.on')(...args);
},
off(...args: unknown[]): void {
action('nextRouter.events.off')(...args);
},
emit(...args: unknown[]): void {
action('nextRouter.events.emit')(...args);
},
},
isFallback: false,
};
export const RouterDecorator = (
Story: React.FC,
{ globals, parameters }: Addon_StoryContext
): React.ReactNode => {
const nextRouterParams = parameters.nextRouter ?? {};
const nextAppDirectory = (parameters.nextAppDirectory as NextAppDirectory | undefined) ?? false;
Router.router = {
...defaultRouter,
locale: globals?.locale,
...nextRouterParams,
} as NonNullable<typeof Router.router>;
if (nextAppDirectory) {
return (
<AppRouterProvider
action={action}
routeParams={{
...defaultRouterParams,
...parameters.nextNavigation,
}}
>
<Story />
</AppRouterProvider>
);
}
return (
<RouterContext.Provider value={Router.router as any}>
<PageRouterProvider
action={action}
globals={globals}
routeParams={{
...defaultRouterParams,
...parameters.nextRouter,
}}
>
<Story />
</RouterContext.Provider>
</PageRouterProvider>
);
};

View File

@ -0,0 +1,70 @@
import type { Globals } from '@storybook/csf';
import { RouterContext } from 'next/dist/shared/lib/router-context';
import React from 'react';
import type { RouteParams } from './types';
type PageRouterProviderProps = {
action: (name: string) => (...args: any[]) => void;
routeParams: RouteParams;
globals: Globals;
};
const PageRouterProvider: React.FC<PageRouterProviderProps> = ({
children,
action,
routeParams,
globals,
}) => (
<RouterContext.Provider
value={{
push(...args) {
action('nextRouter.push')(...args);
return Promise.resolve(true);
},
replace(...args) {
action('nextRouter.replace')(...args);
return Promise.resolve(true);
},
reload(...args) {
action('nextRouter.reload')(...args);
},
back(...args) {
action('nextRouter.back')(...args);
},
forward() {
action('nextRouter.forward')();
},
prefetch(...args) {
action('nextRouter.prefetch')(...args);
return Promise.resolve();
},
beforePopState(...args) {
action('nextRouter.beforePopState')(...args);
},
events: {
on(...args) {
action('nextRouter.events.on')(...args);
},
off(...args) {
action('nextRouter.events.off')(...args);
},
emit(...args) {
action('nextRouter.events.emit')(...args);
},
},
locale: globals?.locale,
route: '/',
asPath: '/',
basePath: '/',
isFallback: false,
isLocaleDomain: false,
isReady: true,
isPreview: false,
...routeParams,
}}
>
{children}
</RouterContext.Provider>
);
export default PageRouterProvider;

View File

@ -0,0 +1,7 @@
export type RouteParams = {
pathname: string;
query: Record<string, string>;
[key: string]: any;
};
export type NextAppDirectory = boolean;

View File

@ -72,3 +72,9 @@ export default {
};
export const Default = {};
export const InAppDir = {
parameters: {
nextAppDirectory: true,
},
};

View File

@ -0,0 +1,75 @@
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import React from 'react';
function Component() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const searchParamsList = Array.from(searchParams.entries());
const routerActions = [
{
cb: () => router.back(),
name: 'Go back',
},
{
cb: () => router.forward(),
name: 'Go forward',
},
{
cb: () => router.prefetch('/prefetched-html'),
name: 'Prefetch',
},
{
cb: () => router.push('/push-html', { forceOptimisticNavigation: true }),
name: 'Push HTML',
},
{
cb: () => router.refresh(),
name: 'Refresh',
},
{
cb: () => router.replace('/replaced-html', { forceOptimisticNavigation: true }),
name: 'Replace',
},
];
return (
<div>
<div>pathname: {pathname}</div>
<div>
searchparams:{' '}
<ul>
{searchParamsList.map(([key, value]) => (
<li key={key}>
{key}: {value}
</li>
))}
</ul>
</div>
{routerActions.map(({ cb, name }) => (
<div key={name} style={{ marginBottom: '1em' }}>
<button type="button" onClick={cb}>
{name}
</button>
</div>
))}
</div>
);
}
export default {
component: Component,
parameters: {
nextAppDirectory: true,
nextNavigation: {
pathname: '/hello',
query: {
foo: 'bar',
},
},
},
};
export const Default = {};

View File

@ -0,0 +1,67 @@
import { useRouter } from 'next/router';
import React from 'react';
function Component() {
const router = useRouter();
const searchParams = router.query;
const routerActions = [
{
cb: () => router.back(),
name: 'Go back',
},
{
cb: () => router.forward(),
name: 'Go forward',
},
{
cb: () => router.prefetch('/prefetched-html'),
name: 'Prefetch',
},
{
cb: () => router.push('/push-html', { forceOptimisticNavigation: true }),
name: 'Push HTML',
},
{
cb: () => router.replace('/replaced-html', { forceOptimisticNavigation: true }),
name: 'Replace',
},
];
return (
<div>
<div>pathname: {router.pathname}</div>
<div>
searchparams:{' '}
<ul>
{Object.entries(searchParams).map(([key, value]) => (
<li key={key}>
{key}: {value}
</li>
))}
</ul>
</div>
{routerActions.map(({ cb, name }) => (
<div key={name} style={{ marginBottom: '1em' }}>
<button type="button" onClick={cb}>
{name}
</button>
</div>
))}
</div>
);
}
export default {
component: Component,
parameters: {
nextRouter: {
pathname: '/hello',
query: {
foo: 'bar',
},
},
},
};
export const Default = {};

View File

@ -75,3 +75,9 @@ export default {
} as Meta<typeof Component>;
export const Default: StoryObj<typeof Component> = {};
export const InAppDir: StoryObj<typeof Component> = {
parameters: {
nextAppDirectory: true,
},
};

View File

@ -0,0 +1,76 @@
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
function Component() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const searchParamsList = Array.from(searchParams.entries());
const routerActions = [
{
cb: () => router.back(),
name: 'Go back',
},
{
cb: () => router.forward(),
name: 'Go forward',
},
{
cb: () => router.prefetch('/prefetched-html'),
name: 'Prefetch',
},
{
cb: () => router.push('/push-html', { forceOptimisticNavigation: true }),
name: 'Push HTML',
},
{
cb: () => router.refresh(),
name: 'Refresh',
},
{
cb: () => router.replace('/replaced-html', { forceOptimisticNavigation: true }),
name: 'Replace',
},
];
return (
<div>
<div>pathname: {pathname}</div>
<div>
searchparams:{' '}
<ul>
{searchParamsList.map(([key, value]) => (
<li key={key}>
{key}: {value}
</li>
))}
</ul>
</div>
{routerActions.map(({ cb, name }) => (
<div key={name} style={{ marginBottom: '1em' }}>
<button type="button" onClick={cb}>
{name}
</button>
</div>
))}
</div>
);
}
export default {
component: Component,
parameters: {
nextAppDirectory: true,
nextNavigation: {
pathname: '/hello',
query: {
foo: 'bar',
},
},
},
} as Meta<typeof Component>;
export const Default: StoryObj<typeof Component> = {};