diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx index a8de967825e16..51c749f002b3a 100644 --- a/packages/next/src/client/components/app-router.tsx +++ b/packages/next/src/client/components/app-router.tsx @@ -186,7 +186,7 @@ function useNavigate(dispatch: React.Dispatch): RouterNavigate { return useCallback( (href, navigateType, forceOptimisticNavigation, shouldScroll) => { const url = new URL(addBasePath(href), location.href) - globalMutable.pendingNavigatePath = href + globalMutable.pendingNavigatePath = createHrefFromUrl(url) return dispatch({ type: ACTION_NAVIGATE, diff --git a/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts index d719b84640541..d8c67e507cc95 100644 --- a/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts @@ -163,6 +163,7 @@ export function serverActionReducer( // unblock if a navigation event comes through // while we've suspended on an action if ( + mutable.inFlightServerAction.status !== 'fulfilled' && mutable.globalMutable.pendingNavigatePath && mutable.globalMutable.pendingNavigatePath !== href ) { diff --git a/test/e2e/app-dir/actions-navigation/app/action-after-redirect/actions.js b/test/e2e/app-dir/actions-navigation/app/action-after-redirect/actions.js new file mode 100644 index 0000000000000..6b6be9a63af6e --- /dev/null +++ b/test/e2e/app-dir/actions-navigation/app/action-after-redirect/actions.js @@ -0,0 +1,9 @@ +'use server' + +export async function expensiveCalculation() { + console.log('server action invoked') + // sleep for 1 second + await new Promise((resolve) => setTimeout(resolve, 1000)) + + return Math.random() +} diff --git a/test/e2e/app-dir/actions-navigation/app/action-after-redirect/form.js b/test/e2e/app-dir/actions-navigation/app/action-after-redirect/form.js new file mode 100644 index 0000000000000..665cc690113e9 --- /dev/null +++ b/test/e2e/app-dir/actions-navigation/app/action-after-redirect/form.js @@ -0,0 +1,35 @@ +'use client' + +import React from 'react' +import { expensiveCalculation } from './actions' + +export function Form({ randomNum }) { + const [isPending, setIsPending] = React.useState(false) + const [result, setResult] = React.useState(null) + + async function handleSubmit(event) { + event.preventDefault() + + setIsPending(true) + const result = await expensiveCalculation() + setIsPending(false) + setResult(result) + } + + return ( +
+
+ + {isPending && 'Loading...'} +
+
Server side rendered number: {randomNum}
+ {result &&
RESULT FROM SERVER ACTION: {result}
} +
+ ) +} diff --git a/test/e2e/app-dir/actions-navigation/app/action-after-redirect/page.js b/test/e2e/app-dir/actions-navigation/app/action-after-redirect/page.js new file mode 100644 index 0000000000000..28ea3700aa38c --- /dev/null +++ b/test/e2e/app-dir/actions-navigation/app/action-after-redirect/page.js @@ -0,0 +1,11 @@ +import { Form } from './form' + +export default async function Page() { + const randomNum = Math.random() + + return ( +
+
+
+ ) +} diff --git a/test/e2e/app-dir/actions-navigation/app/layout.js b/test/e2e/app-dir/actions-navigation/app/layout.js new file mode 100644 index 0000000000000..6d7e1ed585862 --- /dev/null +++ b/test/e2e/app-dir/actions-navigation/app/layout.js @@ -0,0 +1,8 @@ +export default function RootLayout({ children }) { + return ( + + + {children} + + ) +} diff --git a/test/e2e/app-dir/actions-navigation/app/middleware-redirect/page.js b/test/e2e/app-dir/actions-navigation/app/middleware-redirect/page.js new file mode 100644 index 0000000000000..0bd21c5bac776 --- /dev/null +++ b/test/e2e/app-dir/actions-navigation/app/middleware-redirect/page.js @@ -0,0 +1,3 @@ +export default function Page() { + return
Hello World
+} diff --git a/test/e2e/app-dir/actions-navigation/app/nested-folder/(foo)/product-category/[...slugs]/actions.js b/test/e2e/app-dir/actions-navigation/app/nested-folder/(foo)/product-category/[...slugs]/actions.js new file mode 100644 index 0000000000000..b9761b427fe99 --- /dev/null +++ b/test/e2e/app-dir/actions-navigation/app/nested-folder/(foo)/product-category/[...slugs]/actions.js @@ -0,0 +1,5 @@ +'use server' + +export async function addToCart() { + console.log('addToCart') +} diff --git a/test/e2e/app-dir/actions-navigation/app/nested-folder/(foo)/product-category/[...slugs]/page.js b/test/e2e/app-dir/actions-navigation/app/nested-folder/(foo)/product-category/[...slugs]/page.js new file mode 100644 index 0000000000000..3095ad98eff06 --- /dev/null +++ b/test/e2e/app-dir/actions-navigation/app/nested-folder/(foo)/product-category/[...slugs]/page.js @@ -0,0 +1,24 @@ +'use client' +import { experimental_useFormStatus as useFormStatus } from 'react-dom' +import { addToCart } from './actions' + +function SubmitButton() { + const { pending } = useFormStatus() + + return ( + + ) +} + +export default async function Page() { + return ( + <> +

Add to cart

+ + + + + ) +} diff --git a/test/e2e/app-dir/actions-navigation/app/nested-folder/[slug]/page.js b/test/e2e/app-dir/actions-navigation/app/nested-folder/[slug]/page.js new file mode 100644 index 0000000000000..bd5a54fcab5c2 --- /dev/null +++ b/test/e2e/app-dir/actions-navigation/app/nested-folder/[slug]/page.js @@ -0,0 +1,5 @@ +import Link from 'next/link' + +export default async function Page() { + return Machines +} diff --git a/test/e2e/app-dir/actions-navigation/app/nested-folder/page.js b/test/e2e/app-dir/actions-navigation/app/nested-folder/page.js new file mode 100644 index 0000000000000..abac7796778b9 --- /dev/null +++ b/test/e2e/app-dir/actions-navigation/app/nested-folder/page.js @@ -0,0 +1,7 @@ +import Link from 'next/link' + +export default function Page() { + return ( + Go to ../action-after-redirect + ) +} diff --git a/test/e2e/app-dir/actions-navigation/app/page.js b/test/e2e/app-dir/actions-navigation/app/page.js new file mode 100644 index 0000000000000..5fef7be8a121f --- /dev/null +++ b/test/e2e/app-dir/actions-navigation/app/page.js @@ -0,0 +1,9 @@ +import Link from 'next/link' + +export default function Page() { + return ( + + Go to /middleware-redirect + + ) +} diff --git a/test/e2e/app-dir/actions-navigation/index.test.ts b/test/e2e/app-dir/actions-navigation/index.test.ts new file mode 100644 index 0000000000000..e6ab51a5b4c54 --- /dev/null +++ b/test/e2e/app-dir/actions-navigation/index.test.ts @@ -0,0 +1,49 @@ +import { createNextDescribe } from 'e2e-utils' +import { check, waitFor } from 'next-test-utils' + +createNextDescribe( + 'app-dir action handling', + { + files: __dirname, + }, + ({ next }) => { + it('should handle actions correctly after navigation / redirection events', async () => { + const browser = await next.browser('/') + + await browser.elementByCss('#middleware-redirect').click() + + expect(await browser.elementByCss('#form').text()).not.toContain( + 'Loading...' + ) + + await browser.elementByCss('#submit').click() + + await check(() => { + return browser.elementByCss('#form').text() + }, /Loading.../) + + // wait for 2 seconds, since the action takes a second to resolve + await waitFor(2000) + + expect(await browser.elementByCss('#form').text()).not.toContain( + 'Loading...' + ) + + expect(await browser.elementByCss('#result').text()).toContain( + 'RESULT FROM SERVER ACTION' + ) + }) + + it('should handle actions correctly after following a relative link', async () => { + const browser = await next.browser('/nested-folder/products') + + await browser.elementByCss('a').click() + + await browser.elementByCss('button').click() + + await check(() => { + return (next.cliOutput.match(/addToCart/g) || []).length + }, 1) + }) + } +) diff --git a/test/e2e/app-dir/actions-navigation/middleware.js b/test/e2e/app-dir/actions-navigation/middleware.js new file mode 100644 index 0000000000000..45398601ca22f --- /dev/null +++ b/test/e2e/app-dir/actions-navigation/middleware.js @@ -0,0 +1,9 @@ +import { NextResponse } from 'next/server' + +export function middleware(request) { + if (request.nextUrl.pathname.startsWith('/middleware-redirect')) { + return NextResponse.redirect(new URL('/action-after-redirect', request.url)) + } +} + +export const matcher = ['/middleware-redirect'] diff --git a/test/e2e/app-dir/actions-navigation/next.config.js b/test/e2e/app-dir/actions-navigation/next.config.js new file mode 100644 index 0000000000000..6b81f43169761 --- /dev/null +++ b/test/e2e/app-dir/actions-navigation/next.config.js @@ -0,0 +1,5 @@ +module.exports = { + experimental: { + serverActions: true, + }, +} diff --git a/test/e2e/app-dir/actions/app-action.test.ts b/test/e2e/app-dir/actions/app-action.test.ts index e2a61af7f30cd..6a6865985e88f 100644 --- a/test/e2e/app-dir/actions/app-action.test.ts +++ b/test/e2e/app-dir/actions/app-action.test.ts @@ -1,6 +1,6 @@ /* eslint-disable jest/no-standalone-expect */ import { createNextDescribe } from 'e2e-utils' -import { check } from 'next-test-utils' +import { check, waitFor } from 'next-test-utils' import { Request } from 'playwright-chromium' import fs from 'fs-extra' import { join } from 'path' @@ -436,9 +436,7 @@ createNextDescribe( it('should handle redirect to a relative URL in a single pass', async () => { const browser = await next.browser('/client/edge') - await new Promise((resolve) => { - setTimeout(resolve, 3000) - }) + await waitFor(3000) let requests = [] @@ -505,9 +503,7 @@ createNextDescribe( it('should handle redirect to a relative URL in a single pass', async () => { const browser = await next.browser('/client') - await new Promise((resolve) => { - setTimeout(resolve, 3000) - }) + await waitFor(3000) let requests = [] diff --git a/test/e2e/app-dir/actions/app/test/page.js b/test/e2e/app-dir/actions/app/test/page.js new file mode 100644 index 0000000000000..d91572fde352c --- /dev/null +++ b/test/e2e/app-dir/actions/app/test/page.js @@ -0,0 +1,9 @@ +import Link from 'next/link' + +export default function Page() { + return ( +
+ Go to /middleware-redirect +
+ ) +}