From 640213e056bffd520215c85246c40c125e8375a5 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Fri, 19 Apr 2024 13:35:51 +0200 Subject: [PATCH 01/12] Implement redirect and Redirect error boundary --- .../src/export-mocks/navigation/index.ts | 39 +++++++++++-------- .../nextjs/src/fastRefresh/webpack.ts | 9 ++--- .../nextjs/src/routing/decorator.tsx | 5 ++- 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/code/frameworks/nextjs/src/export-mocks/navigation/index.ts b/code/frameworks/nextjs/src/export-mocks/navigation/index.ts index dd9e9a692e6f..81a0ab6f20d2 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.replace): never => { + throw getRedirectError(url, type, RedirectStatusCode.SeeOther); + } +).mockName('next/navigation::redirect'); + +export const permanentRedirect = fn( + (url: string, type: actual.RedirectType = actual.RedirectType.replace): 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/routing/decorator.tsx b/code/frameworks/nextjs/src/routing/decorator.tsx index 4979b6e3b08a..b31aa0f318be 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,9 @@ export const RouterDecorator = ( ...parameters.nextjs?.navigation, }} > - + + + ); } From 3e6e7e2b0b93b466bb753c351cf2f0deae9a5e44 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Fri, 19 Apr 2024 14:51:43 +0200 Subject: [PATCH 02/12] Copy Next patch of console.error --- code/frameworks/nextjs/src/preview.tsx | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/code/frameworks/nextjs/src/preview.tsx b/code/frameworks/nextjs/src/preview.tsx index 8f141e7b1ef0..99d800fe109e 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, From 55759a10311aadbdac1a6df9f7c6cd48472dc544 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 30 Apr 2024 18:11:54 +0200 Subject: [PATCH 03/12] Suppress caught redirect errors in next framework. --- code/frameworks/nextjs/src/preview.tsx | 8 ++++++++ code/lib/react-dom-shim/src/react-18.tsx | 11 ++++++----- code/renderers/react/src/renderToCanvas.tsx | 2 +- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/code/frameworks/nextjs/src/preview.tsx b/code/frameworks/nextjs/src/preview.tsx index 99d800fe109e..9a46a56c6918 100644 --- a/code/frameworks/nextjs/src/preview.tsx +++ b/code/frameworks/nextjs/src/preview.tsx @@ -78,4 +78,12 @@ export const parameters = { excludeDecorators: true, }, }, + react: { + rootOptions: { + onCaughtError(error: unknown) { + if (isNextRouterError(error)) return; + console.error(error); + }, + }, + }, }; diff --git a/code/lib/react-dom-shim/src/react-18.tsx b/code/lib/react-dom-shim/src/react-18.tsx index ddfa738d4dd9..e2f6fb399f22 100644 --- a/code/lib/react-dom-shim/src/react-18.tsx +++ b/code/lib/react-dom-shim/src/react-18.tsx @@ -1,7 +1,8 @@ import type { FC, ReactElement } from 'react'; -import type { Root as ReactRoot } from 'react-dom/client'; +import type { Root as ReactRoot, RootOptions } from 'react-dom/client'; import React, { useLayoutEffect, useRef } from 'react'; import ReactDOM from 'react-dom/client'; +import { type StoryContext } from '@storybook/csf'; // A map of all rendered React 18 nodes const nodes = new Map(); @@ -21,9 +22,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, context: StoryContext) => { // Create Root Element conditionally for new React 18 Root Api - const root = await getReactRoot(el); + const root = await getReactRoot(el, context.parameters.react.rootOptions); return new Promise((resolve) => { root.render( resolve(null)}>{node}); @@ -39,11 +40,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..10241ce2edbb 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); return () => unmountElement(canvasElement); } From c0246d16847cd31dafa6da1019db5961e6ef30f2 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Wed, 1 May 2024 14:08:39 +0200 Subject: [PATCH 04/12] Add test for invalidating state using next redirect --- .../Redirect.stories.tsx | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 code/frameworks/nextjs/template/stories_nextjs-default-ts/Redirect.stories.tsx 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..f5edacff551a --- /dev/null +++ b/code/frameworks/nextjs/template/stories_nextjs-default-ts/Redirect.stories.tsx @@ -0,0 +1,56 @@ +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 is {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 Default: StoryObj = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + await step('Singleton state gets invalidated after redirecting', async () => { + await userEvent.click(canvas.getByRole('button')); + }); + }, +}; From 552405913f2f0f0e37c3cc9851454c169ad00899 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Wed, 1 May 2024 14:12:48 +0200 Subject: [PATCH 05/12] Fix story name --- .../template/stories_nextjs-default-ts/Redirect.stories.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 index f5edacff551a..f099affef649 100644 --- a/code/frameworks/nextjs/template/stories_nextjs-default-ts/Redirect.stories.tsx +++ b/code/frameworks/nextjs/template/stories_nextjs-default-ts/Redirect.stories.tsx @@ -46,11 +46,9 @@ export default { }, } as Meta; -export const Default: StoryObj = { +export const SingletonStateGetsInvalidatedAfterRedirecting: StoryObj = { play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); - await step('Singleton state gets invalidated after redirecting', async () => { - await userEvent.click(canvas.getByRole('button')); - }); + await userEvent.click(canvas.getByRole('button')); }, }; From 393d0f7fcc5abeaefc56aaecee6d25c0d16631de Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Wed, 1 May 2024 14:13:14 +0200 Subject: [PATCH 06/12] Fix story name --- .../template/stories_nextjs-default-ts/Redirect.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index f099affef649..f76d8a7f5a90 100644 --- a/code/frameworks/nextjs/template/stories_nextjs-default-ts/Redirect.stories.tsx +++ b/code/frameworks/nextjs/template/stories_nextjs-default-ts/Redirect.stories.tsx @@ -9,7 +9,7 @@ export default { render() { return (
-
State is {state}
+
{state}
{ state = 'State is invalidated successfully.'; From d6e9002b38e37b1a5189ee5928c6a5b7f9f366c0 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Wed, 1 May 2024 15:54:35 +0200 Subject: [PATCH 07/12] Fix TS --- code/lib/react-dom-shim/src/react-18.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/code/lib/react-dom-shim/src/react-18.tsx b/code/lib/react-dom-shim/src/react-18.tsx index e2f6fb399f22..5b9e88f98a84 100644 --- a/code/lib/react-dom-shim/src/react-18.tsx +++ b/code/lib/react-dom-shim/src/react-18.tsx @@ -1,8 +1,7 @@ import type { FC, ReactElement } from 'react'; -import type { Root as ReactRoot, RootOptions } 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'; -import { type StoryContext } from '@storybook/csf'; // A map of all rendered React 18 nodes const nodes = new Map(); @@ -22,9 +21,9 @@ const WithCallback: FC<{ callback: () => void; children: ReactElement }> = ({ return children; }; -export const renderElement = async (node: ReactElement, el: Element, context: StoryContext) => { +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, context.parameters.react.rootOptions); + const root = await getReactRoot(el, rootOptions); return new Promise((resolve) => { root.render( resolve(null)}>{node}); @@ -40,7 +39,7 @@ export const unmountElement = (el: Element, shouldUseNewRootApi?: boolean) => { } }; -const getReactRoot = async (el: Element, rootOptions: RootOptions): Promise => { +const getReactRoot = async (el: Element, rootOptions?: RootOptions): Promise => { let root = nodes.get(el); if (!root) { From 76a14be066a94d47b6ea6d4fe751e61babde110e Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Wed, 1 May 2024 16:02:25 +0200 Subject: [PATCH 08/12] Fix --- code/renderers/react/src/renderToCanvas.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/renderers/react/src/renderToCanvas.tsx b/code/renderers/react/src/renderToCanvas.tsx index 10241ce2edbb..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, storyContext); + await renderElement(element, canvasElement, storyContext?.parameters?.react?.rootOptions); return () => unmountElement(canvasElement); } From 75f87a5fa1dcb64a66bd3495556ba7cc849a8b65 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 2 May 2024 14:46:01 +0200 Subject: [PATCH 09/12] Fix test --- .../ServerActions.stories.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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..8bfea13b0498 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 @@ -31,6 +31,23 @@ function Component() { export default { component: Component, + 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, + }, + }, } as Meta; export const Default: StoryObj = { From a4b05ec06e212a5dc62a18652dd8b8bb2e2955d4 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 2 May 2024 17:01:53 +0200 Subject: [PATCH 10/12] Cleanup tests --- .../ServerActions.stories.tsx | 78 +++++++++++-------- 1 file changed, 46 insertions(+), 32 deletions(-) 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 8bfea13b0498..f330b2af3b9b 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 @@ -32,6 +32,12 @@ 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. @@ -50,43 +56,51 @@ export default { }, } 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('/'); + }, +}; + +export const ProtectedWhileLoggedIn: StoryObj = { + beforeEach() { + cookies().set('user', 'storybookjs'); + }, + play: async ({ canvasElement }) => { const canvas = within(canvasElement); + await userEvent.click(canvas.getByText('Access protected route')); - const loginBtn = canvas.getByText('Login'); - const logoutBtn = canvas.getByText('Logout'); - const accessRouteBtn = canvas.getByText('Access protected route'); + await expect(cookies().get).toHaveBeenLastCalledWith('user'); + await expect(revalidatePath).toHaveBeenLastCalledWith('/'); + await expect(redirect).toHaveBeenLastCalledWith('/protected'); + }, +}; - await step('accessRoute flow - logged out', async () => { - await userEvent.click(accessRouteBtn); - await expect(cookies().get).toHaveBeenCalledWith('user'); - await expect(redirect).toHaveBeenCalledWith('/'); - }); +export const Logout: StoryObj = { + beforeEach() { + cookies().set('user', 'storybookjs'); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); - 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'); - }); + await userEvent.click(canvas.getByText('Logout')); + await expect(cookies().delete).toHaveBeenCalled(); + await expect(revalidatePath).toHaveBeenCalledWith('/'); + await expect(redirect).toHaveBeenCalledWith('/'); + }, +}; - await step('logout flow', async () => { - cookies.mockRestore(); - await userEvent.click(logoutBtn); - await expect(cookies().delete).toHaveBeenCalled(); - await expect(revalidatePath).toHaveBeenCalledWith('/'); - await expect(redirect).toHaveBeenCalledWith('/'); - }); +export const Login: StoryObj = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByText('Login')); - 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 expect(cookies().set).toHaveBeenCalledWith('user', 'storybookjs'); + await expect(revalidatePath).toHaveBeenCalledWith('/'); + await expect(redirect).toHaveBeenCalledWith('/'); }, }; From a79bebd9fbdca8537f9dce2953e70e81514f461f Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 2 May 2024 17:37:57 +0200 Subject: [PATCH 11/12] Wait for redirect to have happened --- .../nextjs/src/export-mocks/navigation/index.ts | 4 ++-- .../ServerActions.stories.tsx | 12 ++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/code/frameworks/nextjs/src/export-mocks/navigation/index.ts b/code/frameworks/nextjs/src/export-mocks/navigation/index.ts index 81a0ab6f20d2..f55ee86c36ca 100644 --- a/code/frameworks/nextjs/src/export-mocks/navigation/index.ts +++ b/code/frameworks/nextjs/src/export-mocks/navigation/index.ts @@ -59,13 +59,13 @@ export * from 'next/dist/client/components/navigation'; // mock utilities/overrides (as of Next v14.2.0) export const redirect = fn( - (url: string, type: actual.RedirectType = actual.RedirectType.replace): never => { + (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.replace): never => { + (url: string, type: actual.RedirectType = actual.RedirectType.push): never => { throw getRedirectError(url, type, RedirectStatusCode.SeeOther); } ).mockName('next/navigation::permanentRedirect'); 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 f330b2af3b9b..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'; @@ -63,6 +63,8 @@ export const ProtectedWhileLoggedOut: StoryObj = { await expect(cookies().get).toHaveBeenCalledWith('user'); await expect(redirect).toHaveBeenCalledWith('/'); + + await waitFor(() => expect(getRouter().push).toHaveBeenCalled()); }, }; @@ -77,6 +79,8 @@ export const ProtectedWhileLoggedIn: StoryObj = { await expect(cookies().get).toHaveBeenLastCalledWith('user'); await expect(revalidatePath).toHaveBeenLastCalledWith('/'); await expect(redirect).toHaveBeenLastCalledWith('/protected'); + + await waitFor(() => expect(getRouter().push).toHaveBeenCalled()); }, }; @@ -91,6 +95,8 @@ export const Logout: StoryObj = { await expect(cookies().delete).toHaveBeenCalled(); await expect(revalidatePath).toHaveBeenCalledWith('/'); await expect(redirect).toHaveBeenCalledWith('/'); + + await waitFor(() => expect(getRouter().push).toHaveBeenCalled()); }, }; @@ -102,5 +108,7 @@ export const Login: StoryObj = { await expect(cookies().set).toHaveBeenCalledWith('user', 'storybookjs'); await expect(revalidatePath).toHaveBeenCalledWith('/'); await expect(redirect).toHaveBeenCalledWith('/'); + + await waitFor(() => expect(getRouter().push).toHaveBeenCalled()); }, }; From 514c37b50ff72a3972bb9046cac641aecae1a53a Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Fri, 3 May 2024 10:21:48 +0200 Subject: [PATCH 12/12] Add comment describing possible solution for flashing UI with redirects --- code/frameworks/nextjs/src/routing/decorator.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/code/frameworks/nextjs/src/routing/decorator.tsx b/code/frameworks/nextjs/src/routing/decorator.tsx index b31aa0f318be..6c3f66ba6778 100644 --- a/code/frameworks/nextjs/src/routing/decorator.tsx +++ b/code/frameworks/nextjs/src/routing/decorator.tsx @@ -28,6 +28,11 @@ 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. + */}