Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/next/src/build/webpack/plugins/define-env-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ export function getDefineEnv({
isEdgeServer ? 'edge' : isNodeServer ? 'nodejs' : ''
),
'process.env.NEXT_MINIMAL': JSON.stringify(''),
'process.env.__NEXT_WINDOW_HISTORY_SUPPORT': JSON.stringify(
config.experimental.windowHistorySupport
),
'process.env.__NEXT_ACTIONS_DEPLOYMENT_ID': JSON.stringify(
config.experimental.useDeploymentIdServerActions
),
Expand Down
134 changes: 122 additions & 12 deletions packages/next/src/client/components/app-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
PrefetchKind,
} from './router-reducer/router-reducer-types'
import type {
PushRef,
ReducerActions,
RouterChangeByServerResponse,
RouterNavigate,
Expand Down Expand Up @@ -108,24 +109,44 @@ function isExternalURL(url: URL) {
return url.origin !== window.location.origin
}

function HistoryUpdater({ tree, pushRef, canonicalUrl, sync }: any) {
function HistoryUpdater({
tree,
pushRef,
canonicalUrl,
sync,
}: {
tree: FlightRouterState
pushRef: PushRef
canonicalUrl: string
sync: () => void
}) {
useInsertionEffect(() => {
// 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.
const historyState = {
// Keep existing history state to support navigation through e.g. pushState / replaceState outside of Next.js.
...(process.env.__NEXT_WINDOW_HISTORY_SUPPORT
? window.history.state
: undefined),
// 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.
__NA: true,
tree,
__PRIVATE_NEXTJS_INTERNALS_TREE: tree,
}
if (
pushRef.pendingPush &&
// Skip pushing an additional history entry if the canonicalUrl is the same as the current url.
// This mirrors the browser behavior for normal navigation.
createHrefFromUrl(new URL(window.location.href)) !== canonicalUrl
) {
// This intentionally mutates React state, pushRef is overwritten to ensure additional push/replace calls do not trigger an additional history entry.
pushRef.pendingPush = false
window.history.pushState(historyState, '', canonicalUrl)
if (originalPushState) {
originalPushState(historyState, '', canonicalUrl)
}
} else {
window.history.replaceState(historyState, '', canonicalUrl)
if (originalReplaceState) {
originalReplaceState(historyState, '', canonicalUrl)
}
}
sync()
}, [tree, pushRef, canonicalUrl, sync])
Expand Down Expand Up @@ -204,6 +225,28 @@ function useNavigate(dispatch: React.Dispatch<ReducerActions>): RouterNavigate {
)
}

const originalPushState =
typeof window !== 'undefined'
? window.history.pushState.bind(window.history)
: null
const originalReplaceState =
typeof window !== 'undefined'
? window.history.replaceState.bind(window.history)
: null

function copyNextJsInternalHistoryState(data: any) {
const currentState = window.history.state
const __NA = currentState?.__NA
if (__NA) {
data.__NA = __NA
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@timneutkens I believe data can be very plausible be null or undefined here based on the replaceHistory API.

}
const __PRIVATE_NEXTJS_INTERNALS_TREE =
currentState?.__PRIVATE_NEXTJS_INTERNALS_TREE
if (__PRIVATE_NEXTJS_INTERNALS_TREE) {
data.__PRIVATE_NEXTJS_INTERNALS_TREE = __PRIVATE_NEXTJS_INTERNALS_TREE
}
}

/**
* The global router that wraps the application components.
*/
Expand Down Expand Up @@ -371,12 +414,16 @@ function Router({
// 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 || !window.history.state?.tree) return
if (
!event.persisted ||
!window.history.state?.__PRIVATE_NEXTJS_INTERNALS_TREE
)
return

dispatch({
type: ACTION_RESTORE,
url: new URL(window.location.href),
tree: window.history.state.tree,
tree: window.history.state.__PRIVATE_NEXTJS_INTERNALS_TREE,
})
}

Expand Down Expand Up @@ -441,20 +488,83 @@ function Router({
dispatch({
type: ACTION_RESTORE,
url: new URL(window.location.href),
tree: state.tree,
tree: state.__PRIVATE_NEXTJS_INTERNALS_TREE,
})
})
},
[dispatch]
)

// Ensure the canonical URL in the Next.js Router is updated when the URL is changed so that `usePathname` and `useSearchParams` hold the pushed values.
const applyUrlFromHistoryPushReplace = useCallback(
(url: string | URL) => {
startTransition(() => {
dispatch({
type: ACTION_RESTORE,
url: new URL(url ?? window.location.href),
tree: window.history.state.__PRIVATE_NEXTJS_INTERNALS_TREE,
})
})
},
[dispatch]
)

// Register popstate event to call onPopstate.
useEffect(() => {
if (process.env.__NEXT_WINDOW_HISTORY_SUPPORT) {
if (originalPushState) {
/**
* Patch pushState to ensure external changes to the history are reflected in the Next.js Router.
* Ensures Next.js internal history state is copied to the new history entry.
* Ensures usePathname and useSearchParams hold the newly provided url.
*/
window.history.pushState = function pushState(
data: any,
_unused: string,
url?: string | URL | null
): void {
copyNextJsInternalHistoryState(data)
data.__PRIVATE_NEXTJS_INTERNALS_CUSTOM_DATA = true

if (url) {
applyUrlFromHistoryPushReplace(url)
}
return originalPushState(data, _unused, url)
}
}
if (originalReplaceState) {
/**
* Patch replaceState to ensure external changes to the history are reflected in the Next.js Router.
* Ensures Next.js internal history state is copied to the new history entry.
* Ensures usePathname and useSearchParams hold the newly provided url.
*/
window.history.replaceState = function replaceState(
data: any,
_unused: string,
url?: string | URL | null
): void {
copyNextJsInternalHistoryState(data)
data.__PRIVATE_NEXTJS_INTERNALS_CUSTOM_DATA = true

if (url) {
applyUrlFromHistoryPushReplace(url)
}
return originalReplaceState(data, _unused, url)
}
}
}

// Register popstate event to call onPopstate.
window.addEventListener('popstate', onPopState)
return () => {
if (originalPushState) {
window.history.pushState = originalPushState
}
if (originalReplaceState) {
window.history.replaceState = originalReplaceState
}
window.removeEventListener('popstate', onPopState)
}
}, [onPopState])
}, [onPopState, applyUrlFromHistoryPushReplace])

const { cache, tree, nextUrl, focusAndScrollRef } =
useUnwrapState(reducerState)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ export interface PrefetchAction {
kind: PrefetchKind
}

interface PushRef {
export interface PushRef {
/**
* If the app-router should push a new history entry in app-router's useEffect()
*/
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/server/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
excludeDefaultMomentLocales: z.boolean().optional(),
experimental: z
.strictObject({
windowHistorySupport: z.boolean().optional(),
appDocumentPreloading: z.boolean().optional(),
adjustFontFallbacks: z.boolean().optional(),
adjustFontFallbacksWithSizeAdjust: z.boolean().optional(),
Expand Down
2 changes: 2 additions & 0 deletions packages/next/src/server/config-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ export interface NextJsWebpackConfig {
}

export interface ExperimentalConfig {
windowHistorySupport?: boolean
caseSensitiveRoutes?: boolean
useDeploymentId?: boolean
useDeploymentIdServerActions?: boolean
Expand Down Expand Up @@ -741,6 +742,7 @@ export const defaultConfig: NextConfig = {
output: !!process.env.NEXT_PRIVATE_STANDALONE ? 'standalone' : undefined,
modularizeImports: undefined,
experimental: {
windowHistorySupport: false,
serverMinification: true,
serverSourceMaps: false,
caseSensitiveRoutes: false,
Expand Down
7 changes: 7 additions & 0 deletions test/e2e/app-dir/shallow-routing/app/(shallow)/a/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function Page() {
return (
<>
<h1 id="page-a">Page A</h1>
</>
)
}
7 changes: 7 additions & 0 deletions test/e2e/app-dir/shallow-routing/app/(shallow)/b/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function Page() {
return (
<>
<h1 id="page-b">Page B</h1>
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function Page({ params }) {
return (
<>
<h1 id={`page-id-${params.id}`}>Page ID: {params.id}</h1>
</>
)
}
81 changes: 81 additions & 0 deletions test/e2e/app-dir/shallow-routing/app/(shallow)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import Link from 'next/link'

export default function ShallowLayout({ children }) {
return (
<>
<h1>Shallow Routing</h1>
<div>
<div>
<Link href="/a" id="to-a">
To A
</Link>
</div>
<div>
<a href="/a" id="to-a-mpa">
To A MPA Navigation
</a>
</div>
<div>
<Link href="/b" id="to-b">
To B
</Link>
</div>
<div>
<a href="/b" id="to-b-mpa">
To B MPA Navigation
</a>
</div>
<div>
<Link href="/dynamic/1" id="to-dynamic-1">
To Dynamic 1
</Link>
</div>
<div>
<Link href="/dynamic/2" id="to-dynamic-2">
To Dynamic 2
</Link>
</div>
<div>
<Link href="/pushstate-data" id="to-pushstate-data">
To PushState Data
</Link>
</div>
<div>
<Link
href="/pushstate-new-searchparams"
id="to-pushstate-new-searchparams"
>
To PushState new SearchParams
</Link>
</div>
<div>
<Link href="/pushstate-new-pathname" id="to-pushstate-new-pathname">
To PushState new pathname
</Link>
</div>
<div>
<Link href="/replacestate-data" id="to-replacestate-data">
To ReplaceState Data
</Link>
</div>
<div>
<Link
href="/replacestate-new-searchparams"
id="to-replacestate-new-searchparams"
>
To ReplaceState new SearchParams
</Link>
</div>
<div>
<Link
href="/replacestate-new-pathname"
id="to-replacestate-new-pathname"
>
To ReplaceState new pathname
</Link>
</div>
</div>
{children}
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use client'
import { useEffect, useState } from 'react'

export default function Page() {
const [data, setData] = useState(null)
const [updated, setUpdated] = useState(false)
useEffect(() => {
setData(window.history.state.myData)
}, [])
return (
<>
<h1 id="pushstate-data">PushState Data</h1>
{updated ? <div id="state-updated"></div> : null}
<pre id="my-data">{JSON.stringify(data)}</pre>
<button
onClick={() => {
setData(window.history.state.myData)
}}
id="get-latest"
>
Get latest data
</button>
<button
onClick={() => {
window.history.pushState({ myData: { foo: 'bar' } }, '')
setUpdated(true)
}}
id="push-state"
>
Push state
</button>
</>
)
}
Loading