diff --git a/code/frameworks/nextjs/src/export-mocks/navigation/index.ts b/code/frameworks/nextjs/src/export-mocks/navigation/index.ts index dd9e9a692e6f..f55ee86c36ca 100644 --- a/code/frameworks/nextjs/src/export-mocks/navigation/index.ts +++ b/code/frameworks/nextjs/src/export-mocks/navigation/index.ts @@ -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; @@ -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'); diff --git a/code/frameworks/nextjs/src/fastRefresh/webpack.ts b/code/frameworks/nextjs/src/fastRefresh/webpack.ts index 83e91518383d..f9bb9d6c51e7 100644 --- a/code/frameworks/nextjs/src/fastRefresh/webpack.ts +++ b/code/frameworks/nextjs/src/fastRefresh/webpack.ts @@ -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 }), ]; }; diff --git a/code/frameworks/nextjs/src/preview.tsx b/code/frameworks/nextjs/src/preview.tsx index 8f141e7b1ef0..9a46a56c6918 100644 --- a/code/frameworks/nextjs/src/preview.tsx +++ b/code/frameworks/nextjs/src/preview.tsx @@ -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'); @@ -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[] = [ StyledJsxDecorator, ImageDecorator, @@ -52,4 +78,12 @@ export const parameters = { excludeDecorators: true, }, }, + react: { + rootOptions: { + onCaughtError(error: unknown) { + if (isNextRouterError(error)) return; + console.error(error); + }, + }, + }, }; diff --git a/code/frameworks/nextjs/src/routing/decorator.tsx b/code/frameworks/nextjs/src/routing/decorator.tsx index 4979b6e3b08a..6c3f66ba6778 100644 --- a/code/frameworks/nextjs/src/routing/decorator.tsx +++ b/code/frameworks/nextjs/src/routing/decorator.tsx @@ -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: '/', @@ -27,7 +28,14 @@ export const RouterDecorator = ( ...parameters.nextjs?.navigation, }} > - + {/* + 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. + */} + + + ); } diff --git a/code/frameworks/nextjs/template/stories_nextjs-default-ts/Redirect.stories.tsx b/code/frameworks/nextjs/template/stories_nextjs-default-ts/Redirect.stories.tsx new file mode 100644 index 000000000000..f76d8a7f5a90 --- /dev/null +++ b/code/frameworks/nextjs/template/stories_nextjs-default-ts/Redirect.stories.tsx @@ -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 ( +
+
{state}
+
{ + state = 'State is invalidated successfully.'; + redirect('/'); + }} + > + +
+
+ ); + }, + 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')); + }, +}; diff --git a/code/frameworks/nextjs/template/stories_nextjs-default-ts/ServerActions.stories.tsx b/code/frameworks/nextjs/template/stories_nextjs-default-ts/ServerActions.stories.tsx index 17d364429726..cb3f5bbdb2dd 100644 --- a/code/frameworks/nextjs/template/stories_nextjs-default-ts/ServerActions.stories.tsx +++ b/code/frameworks/nextjs/template/stories_nextjs-default-ts/ServerActions.stories.tsx @@ -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'; @@ -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; -export const Default: StoryObj = { - play: async ({ canvasElement, step }) => { +export const ProtectedWhileLoggedOut: StoryObj = { + 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 = { + 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 = { + 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 = { + 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()); }, }; diff --git a/code/lib/react-dom-shim/src/react-18.tsx b/code/lib/react-dom-shim/src/react-18.tsx index ddfa738d4dd9..5b9e88f98a84 100644 --- a/code/lib/react-dom-shim/src/react-18.tsx +++ b/code/lib/react-dom-shim/src/react-18.tsx @@ -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 @@ -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( resolve(null)}>{node}); @@ -39,11 +39,11 @@ export const unmountElement = (el: Element, shouldUseNewRootApi?: boolean) => { } }; -const getReactRoot = async (el: Element): Promise => { +const getReactRoot = async (el: Element, rootOptions?: RootOptions): Promise => { let root = nodes.get(el); if (!root) { - root = ReactDOM.createRoot(el); + root = ReactDOM.createRoot(el, rootOptions); nodes.set(el, root); } diff --git a/code/renderers/react/src/renderToCanvas.tsx b/code/renderers/react/src/renderToCanvas.tsx index d8821a3458e4..8cb2e76f9b2d 100644 --- a/code/renderers/react/src/renderToCanvas.tsx +++ b/code/renderers/react/src/renderToCanvas.tsx @@ -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); }