Skip to content

Commit

Permalink
app router: Fix infinite redirect loop in MPA navigation (#49058)
Browse files Browse the repository at this point in the history
Not 100% convinced that this is the correct fix but it's the best I can
think of.

Previously, we would sometimes call location.assign()/.replace()
hundreds of times (or more) as I described in
#48438 and
#48309 (comment).
Sometimes this would just make things slow but the navigation would
eventually succeed; sometimes this would just hang the browser.

Now we trigger it only once (or—for a reason I don't quite
understand—twice in dev, as you can see in the test) and never commit
that render.

This also fixes the bug I mentioned in
#48438 (comment)
where usePathname() and useSearchParams() would return the page we are
navigating to (even if that's an external page wholly unrelated to our
site!).

Fixes #48309, fixes #48438.

link NEXT-1028

Co-authored-by: Jimmy Lai <laijimmy0@gmail.com>
  • Loading branch information
sophiebits and feedthejim committed May 3, 2023
1 parent 931c101 commit 05cd515
Show file tree
Hide file tree
Showing 4 changed files with 207 additions and 12 deletions.
36 changes: 25 additions & 11 deletions packages/next/src/client/components/app-router.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

import type { ReactNode } from 'react'
import React, { useEffect, useMemo, useCallback } from 'react'
import React, { use, useEffect, useMemo, useCallback } from 'react'
import {
AppRouterContext,
LayoutRouterContext,
Expand Down Expand Up @@ -44,6 +44,7 @@ import { AppRouterAnnouncer } from './app-router-announcer'
import { RedirectBoundary } from './redirect-boundary'
import { NotFoundBoundary } from './not-found-boundary'
import { findHeadInCache } from './router-reducer/reducers/find-head-in-cache'
import { createInfinitePromise } from './infinite-promise'

const isServer = typeof window === 'undefined'

Expand Down Expand Up @@ -85,16 +86,6 @@ function isExternalURL(url: URL) {
function HistoryUpdater({ tree, pushRef, canonicalUrl, sync }: any) {
// @ts-ignore TODO-APP: useInsertionEffect is available
React.useInsertionEffect(() => {
// When mpaNavigation flag is set do a hard navigation to the new url.
if (pushRef.mpaNavigation) {
const location = window.location
if (pushRef.pendingPush) {
location.assign(canonicalUrl)
} else {
location.replace(canonicalUrl)
}
return
}
// Identifier is shortened intentionally.
// __NA is used to identify if the history entry can be handled by the app-router.
// __N is used to identify if the history entry can be handled by the old router.
Expand Down Expand Up @@ -315,6 +306,29 @@ function Router({
window.nd = { router: appRouter, cache, prefetchCache, tree }
}

// 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
// commit either (eg: useTransition isPending should stay true until the page
// unloads).
//
// This is a side effect in render. Don't try this at home, kids. It's
// probably safe because we know this is a singleton component and it's never
// in <Offscreen>. At least I hope so. (It will run twice in dev strict mode,
// but that's... fine?)
if (pushRef.mpaNavigation) {
const location = window.location
if (pushRef.pendingPush) {
location.assign(canonicalUrl)
} else {
location.replace(canonicalUrl)
}
// TODO-APP: Should we listen to navigateerror here to catch failed
// navigations somehow? And should we call window.stop() if a SPA navigation
// should interrupt an MPA one?
use(createInfinitePromise())
}

/**
* Handle popstate event, this is used to handle back/forward in the browser.
* By default dispatches ACTION_RESTORE, however if the history entry was not pushed/replaced by app-router it will reload the page.
Expand Down
71 changes: 71 additions & 0 deletions test/e2e/app-dir/navigation/app/external-push/[storageKey]/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*global navigation*/
'use client'

import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useEffect, useTransition } from 'react'

let listening = false
let startedNavigating = false

export default function Page({ params: { storageKey } }) {
if (typeof window === 'undefined') {
throw new Error('Client render only')
}

let router = useRouter()
let path = usePathname()
let searchParams = useSearchParams().toString()
if (searchParams) {
path += `?${searchParams}`
}

// Log every pathname+searchParams we've seen
sessionStorage.setItem(`${storageKey}/path-${path}`, 'true')

let [isPending, startTransition] = useTransition()
useEffect(() => {
if (startedNavigating) {
sessionStorage.setItem(`${storageKey}/lastIsPending`, isPending)
}
})

// Read all matching logs and print them
let storage = Object.fromEntries(
Object.entries(sessionStorage).flatMap(([key, value]) =>
key.startsWith(`${storageKey}/`)
? [[key.slice(storageKey.length + 1), value]]
: []
)
)

return (
<>
<button
id="go"
onClick={() => {
// Count number of navigations triggered (in browsers that support it)
sessionStorage.setItem(
`${storageKey}/navigation-supported`,
typeof navigation !== 'undefined'
)
if (!listening && typeof navigation !== 'undefined') {
listening = true
navigation.addEventListener('navigate', (event) => {
let key = `${storageKey}/navigate-${event.destination.url}`
let count = +sessionStorage.getItem(key)
sessionStorage.setItem(key, count + 1)
})
}

startedNavigating = true
startTransition(() => {
router.push('https://example.vercel.sh/stuff?abc=123')
})
}}
>
go
</button>
<pre id="storage">{JSON.stringify(storage, null, 2)}</pre>
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*global navigation*/
'use client'

import { redirect, useSearchParams } from 'next/navigation'

let listening = false

export default function Page({ params: { storageKey } }) {
if (typeof window === 'undefined') {
throw new Error('Client render only')
}

let searchParams = useSearchParams()

if (searchParams.get('read')) {
// Read all matching logs and print them
let storage = Object.fromEntries(
Object.entries(sessionStorage).flatMap(([key, value]) =>
key.startsWith(`${storageKey}/`)
? [[key.slice(storageKey.length + 1), value]]
: []
)
)

return <pre id="storage">{JSON.stringify(storage, null, 2)}</pre>
} else {
// Count number of navigations triggered (in browsers that support it)
sessionStorage.setItem(
`${storageKey}/navigation-supported`,
typeof navigation !== 'undefined'
)
if (!listening && typeof navigation !== 'undefined') {
listening = true
navigation.addEventListener('navigate', (event) => {
if (!event.destination.sameDocument) {
let key = `${storageKey}/navigate-${event.destination.url}`
let count = +sessionStorage.getItem(key)
sessionStorage.setItem(key, count + 1)
}
})
}

redirect('https://example.vercel.sh/')
}
}
67 changes: 66 additions & 1 deletion test/e2e/app-dir/navigation/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ createNextDescribe(
{
files: __dirname,
},
({ next, isNextDeploy }) => {
({ next, isNextDev, isNextDeploy }) => {
describe('query string', () => {
it('should set query correctly', async () => {
const browser = await webdriver(next.url, '/')
Expand Down Expand Up @@ -162,6 +162,34 @@ createNextDescribe(
'Example Domain'
)
})

it('should redirect to external url, initiating only once', async () => {
const storageKey = Math.random()
const browser = await next.browser(
`/redirect/external-log/${storageKey}`
)
expect(await browser.waitForElementByCss('h1').text()).toBe(
'Example Domain'
)

// Now check the logs...
await browser.get(
`${next.url}/redirect/external-log/${storageKey}?read=1`
)
const stored = JSON.parse(await browser.elementByCss('pre').text())

if (stored['navigation-supported'] === 'false') {
// Old browser. Can't know how many times we navigated. Oh well.
return
}

expect(stored).toEqual({
// Not actually sure why this is '2' in dev. Possibly something
// related to an update triggered by <HotReload>?
'navigate-https://example.vercel.sh/': isNextDev ? '2' : '1',
'navigation-supported': 'true',
})
})
})

describe('next.config.js redirects', () => {
Expand Down Expand Up @@ -220,6 +248,43 @@ createNextDescribe(
})
})

describe('external push', () => {
it('should push external url without affecting hooks', async () => {
// Log with sessionStorage to persist across navigations
const storageKey = Math.random()
const browser = await next.browser(`/external-push/${storageKey}`)
await browser.elementByCss('#go').click()
await browser.waitForCondition(
'window.location.origin === "https://example.vercel.sh"'
)

// Now check the logs...
await browser.get(`${next.url}/external-push/${storageKey}`)
const stored = JSON.parse(await browser.elementByCss('pre').text())
let expected = {
// Only one navigation
'navigate-https://example.vercel.sh/stuff?abc=123': '1',
'navigation-supported': 'true',
// Make sure /stuff?abc=123 is not logged here
[`path-/external-push/${storageKey}`]: 'true',
// isPending should have been true until the page unloads
lastIsPending: 'true',
}

if (stored['navigation-supported'] !== 'true') {
// Old browser. Can't know how many times we navigated. Oh well.
expected['navigation-supported'] = 'false'
for (const key in expected) {
if (key.startsWith('navigate-')) {
delete expected[key]
}
}
}

expect(stored).toEqual(expected)
})
})

describe('nested navigation', () => {
it('should navigate to nested pages', async () => {
const browser = await next.browser('/nested-navigation')
Expand Down

0 comments on commit 05cd515

Please sign in to comment.