diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx index e85da413499a2..142fcfbf0ab4c 100644 --- a/packages/next/src/client/components/app-router.tsx +++ b/packages/next/src/client/components/app-router.tsx @@ -183,6 +183,7 @@ export function createEmptyCacheNode(): CacheNode { rsc: null, prefetchRsc: null, parallelRoutes: new Map(), + lazyDataResolved: false, } } @@ -578,7 +579,6 @@ function Router({ return } - // @ts-ignore useTransition exists // TODO-APP: Ideally the back button should not use startTransition as it should apply the updates synchronously // Without startTransition works if the cache is there for this path startTransition(() => { diff --git a/packages/next/src/client/components/layout-router.tsx b/packages/next/src/client/components/layout-router.tsx index 75b630dadc4ae..8ed97f30e876f 100644 --- a/packages/next/src/client/components/layout-router.tsx +++ b/packages/next/src/client/components/layout-router.tsx @@ -355,6 +355,7 @@ function InnerLayoutRouter({ prefetchRsc: null, head: null, parallelRoutes: new Map(), + lazyDataResolved: false, } /** @@ -411,6 +412,7 @@ function InnerLayoutRouter({ context.nextUrl, buildId ) + childNode.lazyDataResolved = false } /** @@ -419,15 +421,22 @@ function InnerLayoutRouter({ // When the data has not resolved yet `use` will suspend here. const serverResponse = use(lazyData) - // setTimeout is used to start a new transition during render, this is an intentional hack around React. - setTimeout(() => { - startTransition(() => { - changeByServerResponse({ - previousTree: fullTree, - serverResponse, + if (!childNode.lazyDataResolved) { + // setTimeout is used to start a new transition during render, this is an intentional hack around React. + setTimeout(() => { + startTransition(() => { + changeByServerResponse({ + previousTree: fullTree, + serverResponse, + }) }) }) - }) + + // It's important that we mark this as resolved, in case this branch is replayed, we don't want to continously re-apply + // the patch to the tree. + childNode.lazyDataResolved = true + } + // Suspend infinitely as `changeByServerResponse` will cause a different part of the tree to be rendered. use(createInfinitePromise()) as never } diff --git a/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.test.tsx b/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.test.tsx index 966ca487d4a53..dadf679a28a6d 100644 --- a/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.test.tsx +++ b/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.test.tsx @@ -182,6 +182,7 @@ describe('navigateReducer', () => { "buildId": "development", "cache": { "lazyData": null, + "lazyDataResolved": false, "parallelRoutes": Map { "children" => Map { "linking" => { @@ -402,6 +403,7 @@ describe('navigateReducer', () => { "buildId": "development", "cache": { "lazyData": null, + "lazyDataResolved": false, "parallelRoutes": Map { "children" => Map { "linking" => { @@ -1142,6 +1144,7 @@ describe('navigateReducer', () => { "buildId": "development", "cache": { "lazyData": null, + "lazyDataResolved": false, "parallelRoutes": Map { "children" => Map { "linking" => { @@ -1406,6 +1409,7 @@ describe('navigateReducer', () => { "buildId": "development", "cache": { "lazyData": null, + "lazyDataResolved": false, "parallelRoutes": Map { "children" => Map { "parallel-tab-bar" => { @@ -1824,6 +1828,7 @@ describe('navigateReducer', () => { "buildId": "development", "cache": { "lazyData": null, + "lazyDataResolved": false, "parallelRoutes": Map { "children" => Map { "linking" => { diff --git a/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.test.tsx b/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.test.tsx index d2194a7989da7..7471b082cf588 100644 --- a/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.test.tsx +++ b/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.test.tsx @@ -165,6 +165,7 @@ describe('serverPatchReducer', () => { "buildId": "development", "cache": { "lazyData": null, + "lazyDataResolved": false, "parallelRoutes": Map { "children" => Map { "linking" => { @@ -378,6 +379,7 @@ describe('serverPatchReducer', () => { "buildId": "development", "cache": { "lazyData": null, + "lazyDataResolved": false, "parallelRoutes": Map { "children" => Map { "linking" => { diff --git a/packages/next/src/shared/lib/app-router-context.shared-runtime.ts b/packages/next/src/shared/lib/app-router-context.shared-runtime.ts index 861763dea073a..b02c4993b586e 100644 --- a/packages/next/src/shared/lib/app-router-context.shared-runtime.ts +++ b/packages/next/src/shared/lib/app-router-context.shared-runtime.ts @@ -17,6 +17,11 @@ export type ChildSegmentMap = Map export type CacheNode = ReadyCacheNode | LazyCacheNode export type LazyCacheNode = { + /** + * Whether the lazy cache node data promise has been resolved. + * This value is only true after we've called `use` on the promise (and applied the data to the tree). + */ + lazyDataResolved?: boolean /** * When rsc is null, this is a lazily-initialized cache node. * @@ -59,6 +64,11 @@ export type LazyCacheNode = { } export type ReadyCacheNode = { + /** + * Whether the lazy cache node data promise has been resolved. + * This value is only true after we've called `use` on the promise (and applied the data to the tree). + */ + lazyDataResolved?: boolean /** * When rsc is not null, it represents the RSC data for the * corresponding segment. diff --git a/packages/next/src/shared/lib/router/action-queue.ts b/packages/next/src/shared/lib/router/action-queue.ts index 03ce7accb2097..441487824ab24 100644 --- a/packages/next/src/shared/lib/router/action-queue.ts +++ b/packages/next/src/shared/lib/router/action-queue.ts @@ -140,8 +140,8 @@ function dispatchAction( const newAction: ActionQueueNode = { payload, next: null, - resolve: resolvers!.resolve, - reject: resolvers!.reject, + resolve: resolvers.resolve, + reject: resolvers.reject, } // Check if the queue is empty diff --git a/test/e2e/app-dir/navigation/app/popstate-revalidate/foo/action.ts b/test/e2e/app-dir/navigation/app/popstate-revalidate/foo/action.ts new file mode 100644 index 0000000000000..15bd5fe08973e --- /dev/null +++ b/test/e2e/app-dir/navigation/app/popstate-revalidate/foo/action.ts @@ -0,0 +1,8 @@ +'use server' + +import { revalidatePath } from 'next/cache' + +export async function action() { + revalidatePath('/', 'layout') + return true +} diff --git a/test/e2e/app-dir/navigation/app/popstate-revalidate/foo/page.tsx b/test/e2e/app-dir/navigation/app/popstate-revalidate/foo/page.tsx new file mode 100644 index 0000000000000..75723b799c2ea --- /dev/null +++ b/test/e2e/app-dir/navigation/app/popstate-revalidate/foo/page.tsx @@ -0,0 +1,20 @@ +'use client' + +import { useFormState } from 'react-dom' +import { action } from './action' + +export default function Page() { + const [submitted, formAction] = useFormState(action, false) + if (submitted) { + return
Form Submitted.
+ } + + return ( +
+

Form

+
+ +
+
+ ) +} diff --git a/test/e2e/app-dir/navigation/app/popstate-revalidate/page.tsx b/test/e2e/app-dir/navigation/app/popstate-revalidate/page.tsx new file mode 100644 index 0000000000000..765dbba8fa3e5 --- /dev/null +++ b/test/e2e/app-dir/navigation/app/popstate-revalidate/page.tsx @@ -0,0 +1,12 @@ +import Link from 'next/link' + +export default async function Home() { + await new Promise((resolve) => setTimeout(resolve, 1500)) + + return ( +
+

Home

+ To /foo +
+ ) +} diff --git a/test/e2e/app-dir/navigation/navigation.test.ts b/test/e2e/app-dir/navigation/navigation.test.ts index 89bd248e827f8..9e8cace157713 100644 --- a/test/e2e/app-dir/navigation/navigation.test.ts +++ b/test/e2e/app-dir/navigation/navigation.test.ts @@ -862,5 +862,28 @@ createNextDescribe( }) }) }) + + describe('browser back to a revalidated page', () => { + it('should load the page', async () => { + const browser = await next.browser('/popstate-revalidate') + expect(await browser.elementByCss('h1').text()).toBe('Home') + await browser.elementByCss("[href='/popstate-revalidate/foo']").click() + await browser.waitForElementByCss('#submit-button') + expect(await browser.elementByCss('h1').text()).toBe('Form') + await browser.elementById('submit-button').click() + + await retry(async () => { + expect(await browser.elementByCss('body').text()).toContain( + 'Form Submitted.' + ) + }) + + await browser.back() + + await retry(async () => { + expect(await browser.elementByCss('h1').text()).toBe('Home') + }) + }) + }) } )