diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx index 21ba64f0192b4..9635e58fa0552 100644 --- a/packages/next/src/client/components/app-router.tsx +++ b/packages/next/src/client/components/app-router.tsx @@ -367,6 +367,28 @@ function Router({ }, [appRouter, cache, prefetchCache, tree]) } + useEffect(() => { + // If the app is restored from bfcache, it's possible that + // pushRef.mpaNavigation is true, which would mean that any re-render of this component + // would trigger the mpa navigation logic again from the lines below. + // This will restore the router to the initial state in the event that the app is restored from bfcache. + function handlePageShow(event: PageTransitionEvent) { + if (!event.persisted) return + + dispatch({ + type: ACTION_RESTORE, + url: new URL(window.location.href), + tree: window.history.state, + }) + } + + window.addEventListener('pageshow', handlePageShow) + + return () => { + window.removeEventListener('pageshow', handlePageShow) + } + }, [dispatch, initialTree]) + // When mpaNavigation flag is set do a hard navigation to the new url. // Infinitely suspend because we don't actually want to rerender any child // components with the new URL and any entangled state updates shouldn't diff --git a/test/lib/browsers/base.ts b/test/lib/browsers/base.ts index 24210848f83c1..3ac693da9e86a 100644 --- a/test/lib/browsers/base.ts +++ b/test/lib/browsers/base.ts @@ -53,7 +53,8 @@ export abstract class BrowserInterface implements PromiseLike { async setup( browserName: string, locale: string, - javaScriptEnabled: boolean + javaScriptEnabled: boolean, + headless: boolean ): Promise {} async close(): Promise {} async quit(): Promise {} diff --git a/test/lib/browsers/playwright.ts b/test/lib/browsers/playwright.ts index 6272cae368180..024b54afa3b50 100644 --- a/test/lib/browsers/playwright.ts +++ b/test/lib/browsers/playwright.ts @@ -53,8 +53,12 @@ export class Playwright extends BrowserInterface { this.eventCallbacks[event]?.delete(cb) } - async setup(browserName: string, locale: string, javaScriptEnabled: boolean) { - const headless = !!process.env.HEADLESS + async setup( + browserName: string, + locale: string, + javaScriptEnabled: boolean, + headless: boolean + ) { let device if (process.env.DEVICE_NAME) { @@ -106,6 +110,7 @@ export class Playwright extends BrowserInterface { return await chromium.launch({ devtools: !launchOptions.headless, ...launchOptions, + ignoreDefaultArgs: ['--disable-back-forward-cache'], }) } } diff --git a/test/lib/browsers/selenium.ts b/test/lib/browsers/selenium.ts index 75531c408e59b..22916c6e73d4e 100644 --- a/test/lib/browsers/selenium.ts +++ b/test/lib/browsers/selenium.ts @@ -12,7 +12,6 @@ const { BROWSERSTACK, BROWSERSTACK_USERNAME, BROWSERSTACK_ACCESS_KEY, - HEADLESS, CHROME_BIN, LEGACY_SAFARI, SKIP_LOCAL_SELENIUM_SERVER, @@ -46,7 +45,12 @@ export class Selenium extends BrowserInterface { private browserName: string // TODO: support setting locale - async setup(browserName: string, locale: string, javaScriptEnabled: boolean) { + async setup( + browserName: string, + locale: string, + javaScriptEnabled: boolean, + headless: boolean + ) { if (browser) return this.browserName = browserName @@ -155,7 +159,7 @@ export class Selenium extends BrowserInterface { let firefoxOptions = new FireFoxOptions() let safariOptions = new SafariOptions() - if (HEADLESS) { + if (headless) { const screenSize = { width: 1280, height: 720 } chromeOptions = chromeOptions.headless().windowSize(screenSize) as any firefoxOptions = firefoxOptions.headless().windowSize(screenSize) diff --git a/test/lib/next-webdriver.ts b/test/lib/next-webdriver.ts index 6039c6517f078..501fe518d292b 100644 --- a/test/lib/next-webdriver.ts +++ b/test/lib/next-webdriver.ts @@ -65,6 +65,7 @@ export default async function webdriver( beforePageLoad?: (page: any) => void locale?: string disableJavaScript?: boolean + headless?: boolean } ): Promise { let CurrentInterface: new () => BrowserInterface @@ -82,6 +83,7 @@ export default async function webdriver( beforePageLoad, locale, disableJavaScript, + headless, } = options // we import only the needed interface @@ -101,7 +103,13 @@ export default async function webdriver( const browser = new CurrentInterface() const browserName = process.env.BROWSER_NAME || 'chrome' - await browser.setup(browserName, locale, !disableJavaScript) + await browser.setup( + browserName, + locale, + !disableJavaScript, + // allow headless to be overwritten for a particular test + typeof headless !== 'undefined' ? headless : !!process.env.HEADLESS + ) ;(global as any).browserName = browserName const fullUrl = getFullUrl( diff --git a/test/production/export-routing/app/layout.js b/test/production/export-routing/app/layout.js new file mode 100644 index 0000000000000..762515029332e --- /dev/null +++ b/test/production/export-routing/app/layout.js @@ -0,0 +1,8 @@ +export default function Layout({ children }) { + return ( + + + {children} + + ) +} diff --git a/test/production/export-routing/app/page.js b/test/production/export-routing/app/page.js new file mode 100644 index 0000000000000..8998e00c040cd --- /dev/null +++ b/test/production/export-routing/app/page.js @@ -0,0 +1,16 @@ +'use client' +import React from 'react' +import Link from 'next/link' + +export default function Page() { + const [counter, setCounter] = React.useState(0) + return ( +
+ +
{counter}
+ External Page +
+ ) +} diff --git a/test/production/export-routing/index.test.ts b/test/production/export-routing/index.test.ts new file mode 100644 index 0000000000000..62ca0b49b8a8d --- /dev/null +++ b/test/production/export-routing/index.test.ts @@ -0,0 +1,54 @@ +import { Server } from 'http' +import { + findPort, + nextBuild, + startStaticServer, + stopApp, +} from 'next-test-utils' +import webdriver from 'next-webdriver' +import { join } from 'path' + +describe('export-routing', () => { + let port: number + let app: Server + + beforeAll(async () => { + const appDir = __dirname + const exportDir = join(appDir, 'out') + + await nextBuild(appDir, undefined, { cwd: appDir }) + port = await findPort() + app = await startStaticServer(exportDir, undefined, port) + }) + + afterAll(() => { + stopApp(app) + }) + + it('should not suspend indefinitely when page is restored from bfcache after an mpa navigation', async () => { + // bfcache is not currently supported by CDP, so we need to run this particular test in headed mode + // https://bugs.chromium.org/p/chromium/issues/detail?id=1317959 + if (!process.env.CI && process.env.HEADLESS) { + console.warn('This test can only run in headed mode. Skipping...') + return + } + + const browser = await webdriver(port, '/index.html', { headless: false }) + + await browser.elementByCss('a[href="https://example.vercel.sh"]').click() + await browser.waitForCondition( + 'window.location.origin === "https://example.vercel.sh"' + ) + + // this will never resolve in the failure case, as the page will be suspended indefinitely + await browser.back() + + expect(await browser.elementByCss('#counter').text()).toBe('0') + + // click the button + await browser.elementByCss('button').click() + + // counter should be 1 + expect(await browser.elementByCss('#counter').text()).toBe('1') + }) +}) diff --git a/test/production/export-routing/next.config.js b/test/production/export-routing/next.config.js new file mode 100644 index 0000000000000..ac7197dbfbe61 --- /dev/null +++ b/test/production/export-routing/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'export', +} + +module.exports = nextConfig