Skip to content

Commit

Permalink
Merge pull request #26886 from storybookjs/kasper/redirect-boundary
Browse files Browse the repository at this point in the history
Nextjs: Implement next redirect and the RedirectBoundary
  • Loading branch information
kasperpeulen authored May 3, 2024
2 parents 56a9f7b + 514c37b commit 314fbd6
Show file tree
Hide file tree
Showing 8 changed files with 202 additions and 63 deletions.
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');
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 }),
];
};
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'));
},
};
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { expect, within, userEvent } from '@storybook/test';
import { expect, within, userEvent, waitFor } from '@storybook/test';
import { cookies } from '@storybook/nextjs/headers.mock';
import { revalidatePath } from '@storybook/nextjs/cache.mock';
import { redirect } from '@storybook/nextjs/navigation.mock';
import { redirect, getRouter } from '@storybook/nextjs/navigation.mock';

import { accessRoute, login, logout } from './server-actions';

Expand Down Expand Up @@ -31,45 +31,84 @@ function Component() {

export default {
component: Component,
parameters: {
nextjs: {
appDirectory: true,
navigation: {
pathname: '/',
},
},
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,
},
},
} as Meta<typeof Component>;

export const Default: StoryObj<typeof Component> = {
play: async ({ canvasElement, step }) => {
export const ProtectedWhileLoggedOut: StoryObj<typeof Component> = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByText('Access protected route'));

await expect(cookies().get).toHaveBeenCalledWith('user');
await expect(redirect).toHaveBeenCalledWith('/');

await waitFor(() => expect(getRouter().push).toHaveBeenCalled());
},
};

export const ProtectedWhileLoggedIn: StoryObj<typeof Component> = {
beforeEach() {
cookies().set('user', 'storybookjs');
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByText('Access protected route'));

await expect(cookies().get).toHaveBeenLastCalledWith('user');
await expect(revalidatePath).toHaveBeenLastCalledWith('/');
await expect(redirect).toHaveBeenLastCalledWith('/protected');

await waitFor(() => expect(getRouter().push).toHaveBeenCalled());
},
};

export const Logout: StoryObj<typeof Component> = {
beforeEach() {
cookies().set('user', 'storybookjs');
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);

const loginBtn = canvas.getByText('Login');
const logoutBtn = canvas.getByText('Logout');
const accessRouteBtn = canvas.getByText('Access protected route');
await userEvent.click(canvas.getByText('Logout'));
await expect(cookies().delete).toHaveBeenCalled();
await expect(revalidatePath).toHaveBeenCalledWith('/');
await expect(redirect).toHaveBeenCalledWith('/');

await step('accessRoute flow - logged out', async () => {
await userEvent.click(accessRouteBtn);
await expect(cookies().get).toHaveBeenCalledWith('user');
await expect(redirect).toHaveBeenCalledWith('/');
});
await waitFor(() => expect(getRouter().push).toHaveBeenCalled());
},
};

await step('accessRoute flow - logged', async () => {
cookies.mockRestore();
cookies().set('user', 'storybookjs');
await userEvent.click(accessRouteBtn);
await expect(cookies().get).toHaveBeenCalledWith('user');
await expect(revalidatePath).toHaveBeenCalledWith('/');
await expect(redirect).toHaveBeenCalledWith('/protected');
});
export const Login: StoryObj<typeof Component> = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByText('Login'));

await step('logout flow', async () => {
cookies.mockRestore();
await userEvent.click(logoutBtn);
await expect(cookies().delete).toHaveBeenCalled();
await expect(revalidatePath).toHaveBeenCalledWith('/');
await expect(redirect).toHaveBeenCalledWith('/');
});
await expect(cookies().set).toHaveBeenCalledWith('user', 'storybookjs');
await expect(revalidatePath).toHaveBeenCalledWith('/');
await expect(redirect).toHaveBeenCalledWith('/');

await step('login flow', async () => {
cookies.mockRestore();
await userEvent.click(loginBtn);
await expect(cookies().set).toHaveBeenCalledWith('user', 'storybookjs');
await expect(revalidatePath).toHaveBeenCalledWith('/');
await expect(redirect).toHaveBeenCalledWith('/');
});
await waitFor(() => expect(getRouter().push).toHaveBeenCalled());
},
};
10 changes: 5 additions & 5 deletions code/lib/react-dom-shim/src/react-18.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { FC, ReactElement } from 'react';
import type { Root as ReactRoot } from 'react-dom/client';
import React, { useLayoutEffect, useRef } from 'react';
import type { Root as ReactRoot, RootOptions } from 'react-dom/client';
import ReactDOM from 'react-dom/client';

// A map of all rendered React 18 nodes
Expand All @@ -21,9 +21,9 @@ const WithCallback: FC<{ callback: () => void; children: ReactElement }> = ({
return children;
};

export const renderElement = async (node: ReactElement, el: Element) => {
export const renderElement = async (node: ReactElement, el: Element, rootOptions?: RootOptions) => {
// Create Root Element conditionally for new React 18 Root Api
const root = await getReactRoot(el);
const root = await getReactRoot(el, rootOptions);

return new Promise((resolve) => {
root.render(<WithCallback callback={() => resolve(null)}>{node}</WithCallback>);
Expand All @@ -39,11 +39,11 @@ export const unmountElement = (el: Element, shouldUseNewRootApi?: boolean) => {
}
};

const getReactRoot = async (el: Element): Promise<ReactRoot> => {
const getReactRoot = async (el: Element, rootOptions?: RootOptions): Promise<ReactRoot> => {
let root = nodes.get(el);

if (!root) {
root = ReactDOM.createRoot(el);
root = ReactDOM.createRoot(el, rootOptions);
nodes.set(el, root);
}

Expand Down
2 changes: 1 addition & 1 deletion code/renderers/react/src/renderToCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export async function renderToCanvas(
unmountElement(canvasElement);
}

await renderElement(element, canvasElement);
await renderElement(element, canvasElement, storyContext?.parameters?.react?.rootOptions);

return () => unmountElement(canvasElement);
}

0 comments on commit 314fbd6

Please sign in to comment.