Add experimental support for history.pushState and history.replaceState (#58335)

## What?

This PR introduces support for manually calling `history.pushState` and `history.replaceState`.

It's currently under an experimental flag:

```js
/**
 * @type {import('next').NextConfig}
 */
const nextConfig = {
  experimental: {
    windowHistorySupport: true,
  },
}

module.exports = nextConfig
```

Going forward I'll refer to `history.pushState` as `replaceState` is interchangable.
 
When the flag is enabled you're able to call the web platform `history.pushState` in the usual way:

```js
const data = {
	foo: 'bar'
}
const url = '/my-new-url?search=tim'
window.history.pushState(data, '', url)
```

Let's start by explaining what would happen without the flag:

When a new history entry is pushed outside of the Next.js router any back navigation to that history entry will cause a browser reload as it can no longer be used by Next.js as the required metadata for the router is missing. In practice this makes it so that pushState/replaceState is not feasible to be used. Any pathname / searchParams added can't be observed by `usePathname` / `useSearchParams` either.

With the flag enabled the pushState/replaceState calls are instrumented and is synced into the Next.js router. This way the Next.js router's internal metadata is preserved, making back navigations apply still, and pathname / searchParams is synced as well, making sure that you can observe it using `usePathname` and `useSearchParams`.

## How?

- Added a new experimental flag `windowHistorySupport`
- Instruments `history.pushState` and `history.replaceState`
	- Triggers the same action as popstate (ACTION_RESTORE) to sync the provided url (if provided) into the Next.js router
	- Copies the Next.js values kept in history.state so that they are not lost
	- Calls the original pushState/replaceState

~~Something to figure out is how we handle additional pushes/replaces in Next.js as that should override the history state that was previously set.~~
Went with this after discussing with @sebmarkbage:
- When you open a page it preserves the custom history state
	- This is to solve this case: when you manually `window.history.pushState` / `window.history.replaceState` and then do an mpa navigation (i.e. `<a>` or `window.location.href`) and the navigate backwards the custom history state is preserved
- When you navigate back and forward (popstate) it preserves the custom history state
- When you navigate client-side (i.e. `router.push()` / `<Link>`) the custom history state is not preserved
This commit is contained in:
Tim Neutkens 2023-11-13 14:32:08 +01:00 committed by GitHub
parent 76da32e43f
commit 797fecb0c6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 767 additions and 49 deletions

View file

@ -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
),

View file

@ -35,6 +35,7 @@ import {
PrefetchKind,
} from './router-reducer/router-reducer-types'
import type {
PushRef,
ReducerActions,
RouterChangeByServerResponse,
RouterNavigate,
@ -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 = {
...(process.env.__NEXT_WINDOW_HISTORY_SUPPORT &&
pushRef.preserveCustomHistoryState
? window.history.state
: {}),
// 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])
@ -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
}
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.
*/
@ -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,
})
}
@ -416,13 +463,66 @@ function Router({
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.
* That case can happen when the old router injected the history entry.
*/
const onPopState = useCallback(
({ state }: PopStateEvent) => {
useEffect(() => {
if (process.env.__NEXT_WINDOW_HISTORY_SUPPORT) {
// 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 = (
url: string | URL | null | undefined
) => {
startTransition(() => {
dispatch({
type: ACTION_RESTORE,
url: new URL(url ?? window.location.href),
tree: window.history.state.__PRIVATE_NEXTJS_INTERNALS_TREE,
})
})
}
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)
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)
if (url) {
applyUrlFromHistoryPushReplace(url)
}
return originalReplaceState(data, _unused, url)
}
}
}
/**
* 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.
* That case can happen when the old router injected the history entry.
*/
const onPopState = ({ state }: PopStateEvent) => {
if (!state) {
// TODO-APP: this case only happens when pushState/replaceState was called outside of Next.js. It should probably reload the page in this case.
return
@ -441,20 +541,23 @@ function Router({
dispatch({
type: ACTION_RESTORE,
url: new URL(window.location.href),
tree: state.tree,
tree: state.__PRIVATE_NEXTJS_INTERNALS_TREE,
})
})
},
[dispatch]
)
}
// Register popstate event to call onPopstate.
useEffect(() => {
// 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])
}, [dispatch])
const { cache, tree, nextUrl, focusAndScrollRef } =
useUnwrapState(reducerState)

View file

@ -98,7 +98,11 @@ describe('createInitialRouterState', () => {
tree: initialTree,
canonicalUrl: initialCanonicalUrl,
prefetchCache: new Map(),
pushRef: { pendingPush: false, mpaNavigation: false },
pushRef: {
pendingPush: false,
mpaNavigation: false,
preserveCustomHistoryState: true,
},
focusAndScrollRef: {
apply: false,
onlyHashChange: false,

View file

@ -46,7 +46,13 @@ export function createInitialRouterState({
tree: initialTree,
cache,
prefetchCache: new Map(),
pushRef: { pendingPush: false, mpaNavigation: false },
pushRef: {
pendingPush: false,
mpaNavigation: false,
// First render needs to preserve the previous window.history.state
// to avoid it being overwritten on navigation back/forward with MPA Navigation.
preserveCustomHistoryState: true,
},
focusAndScrollRef: {
apply: false,
onlyHashChange: false,

View file

@ -5,6 +5,10 @@ import type {
ReducerState,
} from './router-reducer-types'
function isNotUndefined<T>(value: T): value is Exclude<T, undefined> {
return typeof value !== 'undefined'
}
export function handleMutable(
state: ReadonlyReducerState,
mutable: Mutable
@ -15,26 +19,28 @@ export function handleMutable(
return {
buildId: state.buildId,
// Set href.
canonicalUrl:
mutable.canonicalUrl != null
? mutable.canonicalUrl === state.canonicalUrl
? state.canonicalUrl
: mutable.canonicalUrl
: state.canonicalUrl,
canonicalUrl: isNotUndefined(mutable.canonicalUrl)
? mutable.canonicalUrl === state.canonicalUrl
? state.canonicalUrl
: mutable.canonicalUrl
: state.canonicalUrl,
pushRef: {
pendingPush:
mutable.pendingPush != null
? mutable.pendingPush
: state.pushRef.pendingPush,
mpaNavigation:
mutable.mpaNavigation != null
? mutable.mpaNavigation
: state.pushRef.mpaNavigation,
pendingPush: isNotUndefined(mutable.pendingPush)
? mutable.pendingPush
: state.pushRef.pendingPush,
mpaNavigation: isNotUndefined(mutable.mpaNavigation)
? mutable.mpaNavigation
: state.pushRef.mpaNavigation,
preserveCustomHistoryState: isNotUndefined(
mutable.preserveCustomHistoryState
)
? mutable.preserveCustomHistoryState
: state.pushRef.preserveCustomHistoryState,
},
// All navigation requires scroll and focus management to trigger.
focusAndScrollRef: {
apply: shouldScroll
? mutable?.scrollableSegments !== undefined
? isNotUndefined(mutable?.scrollableSegments)
? true
: state.focusAndScrollRef.apply
: // If shouldScroll is false then we should not apply scroll and focus management.
@ -63,11 +69,12 @@ export function handleMutable(
? mutable.prefetchCache
: state.prefetchCache,
// Apply patched router state.
tree: mutable.patchedTree !== undefined ? mutable.patchedTree : state.tree,
nextUrl:
mutable.patchedTree !== undefined
? computeChangedPath(state.tree, mutable.patchedTree) ??
state.canonicalUrl
: state.nextUrl,
tree: isNotUndefined(mutable.patchedTree)
? mutable.patchedTree
: state.tree,
nextUrl: isNotUndefined(mutable.patchedTree)
? computeChangedPath(state.tree, mutable.patchedTree) ??
state.canonicalUrl
: state.nextUrl,
}
}

View file

@ -26,6 +26,8 @@ function fastRefreshReducerImpl(
return handleMutable(state, mutable)
}
mutable.preserveCustomHistoryState = false
if (!cache.data) {
// TODO-APP: verify that `href` is not an external url.
// Fetch data from the root of the tree.

View file

@ -300,6 +300,7 @@ describe('navigateReducer', () => {
"pushRef": {
"mpaNavigation": false,
"pendingPush": true,
"preserveCustomHistoryState": false,
},
"tree": [
"",
@ -492,6 +493,7 @@ describe('navigateReducer', () => {
"pushRef": {
"mpaNavigation": false,
"pendingPush": true,
"preserveCustomHistoryState": false,
},
"tree": [
"",
@ -656,6 +658,7 @@ describe('navigateReducer', () => {
"pushRef": {
"mpaNavigation": true,
"pendingPush": true,
"preserveCustomHistoryState": false,
},
"tree": [
"",
@ -815,6 +818,7 @@ describe('navigateReducer', () => {
"pushRef": {
"mpaNavigation": true,
"pendingPush": false,
"preserveCustomHistoryState": false,
},
"tree": [
"",
@ -977,6 +981,7 @@ describe('navigateReducer', () => {
"pushRef": {
"mpaNavigation": false,
"pendingPush": true,
"preserveCustomHistoryState": false,
},
"tree": [
"",
@ -1140,6 +1145,7 @@ describe('navigateReducer', () => {
"pushRef": {
"mpaNavigation": true,
"pendingPush": true,
"preserveCustomHistoryState": false,
},
"tree": [
"",
@ -1388,6 +1394,7 @@ describe('navigateReducer', () => {
"pushRef": {
"mpaNavigation": false,
"pendingPush": true,
"preserveCustomHistoryState": false,
},
"tree": [
"",
@ -1640,6 +1647,7 @@ describe('navigateReducer', () => {
"pushRef": {
"mpaNavigation": false,
"pendingPush": true,
"preserveCustomHistoryState": false,
},
"tree": [
"",
@ -1747,6 +1755,7 @@ describe('navigateReducer', () => {
hashFragment: '#hash',
pendingPush: true,
shouldScroll: true,
preserveCustomHistoryState: false,
},
}
@ -1802,6 +1811,7 @@ describe('navigateReducer', () => {
"pushRef": {
"mpaNavigation": false,
"pendingPush": true,
"preserveCustomHistoryState": false,
},
"tree": [
"",
@ -1999,6 +2009,7 @@ describe('navigateReducer', () => {
"pushRef": {
"mpaNavigation": false,
"pendingPush": true,
"preserveCustomHistoryState": false,
},
"tree": [
"",

View file

@ -127,6 +127,8 @@ export function navigateReducer(
return handleMutable(state, mutable)
}
mutable.preserveCustomHistoryState = false
if (isExternalUrl) {
return handleExternalUrl(state, mutable, url.toString(), pendingPush)
}

View file

@ -173,6 +173,7 @@ describe('prefetchReducer', () => {
pushRef: {
mpaNavigation: false,
pendingPush: false,
preserveCustomHistoryState: true,
},
focusAndScrollRef: {
apply: false,
@ -328,6 +329,7 @@ describe('prefetchReducer', () => {
pushRef: {
mpaNavigation: false,
pendingPush: false,
preserveCustomHistoryState: true,
},
focusAndScrollRef: {
apply: false,

View file

@ -151,6 +151,7 @@ describe('refreshReducer', () => {
pushRef: {
mpaNavigation: false,
pendingPush: false,
preserveCustomHistoryState: false,
},
focusAndScrollRef: {
apply: false,
@ -314,6 +315,7 @@ describe('refreshReducer', () => {
pushRef: {
mpaNavigation: false,
pendingPush: false,
preserveCustomHistoryState: false,
},
focusAndScrollRef: {
apply: false,
@ -501,6 +503,7 @@ describe('refreshReducer', () => {
pushRef: {
mpaNavigation: false,
pendingPush: false,
preserveCustomHistoryState: false,
},
focusAndScrollRef: {
apply: false,
@ -737,6 +740,7 @@ describe('refreshReducer', () => {
pushRef: {
mpaNavigation: false,
pendingPush: false,
preserveCustomHistoryState: false,
},
focusAndScrollRef: {
apply: false,

View file

@ -28,6 +28,8 @@ export function refreshReducer(
return handleMutable(state, mutable)
}
mutable.preserveCustomHistoryState = false
if (!cache.data) {
// TODO-APP: verify that `href` is not an external url.
// Fetch data from the root of the tree.

View file

@ -123,6 +123,7 @@ describe('serverPatchReducer', () => {
pushRef: {
mpaNavigation: false,
pendingPush: false,
preserveCustomHistoryState: true,
},
focusAndScrollRef: {
apply: false,
@ -290,6 +291,7 @@ describe('serverPatchReducer', () => {
pushRef: {
mpaNavigation: false,
pendingPush: false,
preserveCustomHistoryState: true,
},
focusAndScrollRef: {
apply: false,

View file

@ -16,7 +16,12 @@ export function restoreReducer(
buildId: state.buildId,
// Set canonical url
canonicalUrl: href,
pushRef: state.pushRef,
pushRef: {
pendingPush: false,
mpaNavigation: false,
// Ensures that the custom history state that was set is preserved when applying this update.
preserveCustomHistoryState: true,
},
focusAndScrollRef: state.focusAndScrollRef,
cache: state.cache,
prefetchCache: state.prefetchCache,

View file

@ -157,6 +157,7 @@ export function serverActionReducer(
return handleMutable(state, mutable)
}
mutable.preserveCustomHistoryState = false
mutable.inFlightServerAction = fetchServerAction(state, action)
// suspends until the server action is resolved.

View file

@ -254,6 +254,7 @@ describe('serverPatchReducer', () => {
"pushRef": {
"mpaNavigation": false,
"pendingPush": false,
"preserveCustomHistoryState": false,
},
"tree": [
"",
@ -425,6 +426,7 @@ describe('serverPatchReducer', () => {
"pushRef": {
"mpaNavigation": false,
"pendingPush": false,
"preserveCustomHistoryState": true,
},
"tree": [
"",
@ -686,6 +688,7 @@ describe('serverPatchReducer', () => {
"pushRef": {
"mpaNavigation": false,
"pendingPush": true,
"preserveCustomHistoryState": false,
},
"tree": [
"",

View file

@ -33,6 +33,8 @@ export function serverPatchReducer(
return handleMutable(state, mutable)
}
mutable.preserveCustomHistoryState = false
// Handle case when navigating to page in `pages` from `app`
if (typeof flightData === 'string') {
return handleExternalUrl(

View file

@ -38,6 +38,7 @@ export interface Mutable {
prefetchCache?: AppRouterState['prefetchCache']
hashFragment?: string
shouldScroll?: boolean
preserveCustomHistoryState?: boolean
}
export interface ServerActionMutable extends Mutable {
@ -180,7 +181,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()
*/
@ -189,6 +190,10 @@ interface PushRef {
* Multi-page navigation through location.href.
*/
mpaNavigation: boolean
/**
* Skip applying the router state to the browser history state.
*/
preserveCustomHistoryState: boolean
}
export type FocusAndScrollRef = {

View file

@ -74,7 +74,7 @@ export interface ReduxDevToolsInstance {
init(initialState: any): void
}
export function useUnwrapState(state: ReducerState) {
export function useUnwrapState(state: ReducerState): AppRouterState {
// reducer actions can be async, so sometimes we need to suspend until the state is resolved
if (isThenable(state)) {
const result = use(state)

View file

@ -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(),

View file

@ -159,6 +159,7 @@ export interface NextJsWebpackConfig {
}
export interface ExperimentalConfig {
windowHistorySupport?: boolean
caseSensitiveRoutes?: boolean
useDeploymentId?: boolean
useDeploymentIdServerActions?: boolean
@ -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,

View file

@ -0,0 +1,7 @@
export default function Page() {
return (
<>
<h1 id="page-a">Page A</h1>
</>
)
}

View file

@ -0,0 +1,7 @@
export default function Page() {
return (
<>
<h1 id="page-b">Page B</h1>
</>
)
}

View file

@ -0,0 +1,7 @@
export default function Page({ params }) {
return (
<>
<h1 id={`page-id-${params.id}`}>Page ID: {params.id}</h1>
</>
)
}

View file

@ -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}
</>
)
}

View file

@ -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>
</>
)
}

View file

@ -0,0 +1,22 @@
'use client'
import { usePathname } from 'next/navigation'
export default function Page() {
const pathname = usePathname()
return (
<>
<h1 id="pushstate-pathname">PushState Pathname</h1>
<pre id="my-data">{pathname}</pre>
<button
onClick={() => {
const url = new URL(window.location.href)
url.pathname = '/my-non-existent-path'
window.history.pushState({}, '', url)
}}
id="push-pathname"
>
Push pathname
</button>
</>
)
}

View file

@ -0,0 +1,26 @@
'use client'
import { useSearchParams } from 'next/navigation'
export default function Page() {
const searchParams = useSearchParams()
return (
<>
<h1 id="pushstate-searchparams">PushState SearchParams</h1>
<pre id="my-data">{searchParams.get('query')}</pre>
<button
onClick={() => {
const url = new URL(window.location.href)
const previousQuery = url.searchParams.get('query')
url.searchParams.set(
'query',
previousQuery ? previousQuery + '-added' : 'foo'
)
window.history.pushState({}, '', url)
}}
id="push-searchparams"
>
Push searchParam
</button>
</>
)
}

View file

@ -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="replacestate-data">ReplaceState 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.replaceState({ myData: { foo: 'bar' } }, '')
setUpdated(true)
}}
id="replace-state"
>
Replace state
</button>
</>
)
}

View file

@ -0,0 +1,22 @@
'use client'
import { usePathname } from 'next/navigation'
export default function Page() {
const pathname = usePathname()
return (
<>
<h1 id="replacestate-pathname">ReplaceState Pathname</h1>
<pre id="my-data">{pathname}</pre>
<button
onClick={() => {
const url = new URL(window.location.href)
url.pathname = '/my-non-existent-path'
window.history.replaceState({}, '', url)
}}
id="replace-pathname"
>
Push pathname
</button>
</>
)
}

View file

@ -0,0 +1,26 @@
'use client'
import { useSearchParams } from 'next/navigation'
export default function Page() {
const searchParams = useSearchParams()
return (
<>
<h1 id="replacestate-searchparams">ReplaceState SearchParams</h1>
<pre id="my-data">{searchParams.get('query')}</pre>
<button
onClick={() => {
const url = new URL(window.location.href)
const previousQuery = url.searchParams.get('query')
url.searchParams.set(
'query',
previousQuery ? previousQuery + '-added' : 'foo'
)
window.history.replaceState({}, '', url)
}}
id="replace-searchparams"
>
Replace searchParam
</button>
</>
)
}

View file

@ -0,0 +1,7 @@
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html>
<body>{children}</body>
</html>
)
}

View file

@ -0,0 +1,7 @@
export default function Page() {
return (
<>
<h1>Shallow Routing</h1>
</>
)
}

View file

@ -0,0 +1,10 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
experimental: {
windowHistorySupport: true,
},
}
module.exports = nextConfig

View file

@ -0,0 +1,261 @@
import { createNextDescribe } from 'e2e-utils'
import { check } from 'next-test-utils'
createNextDescribe(
'shallow-routing',
{
files: __dirname,
},
({ next }) => {
describe('pushState', () => {
it('should support setting data', async () => {
const browser = await next.browser('/a')
expect(
await browser
.elementByCss('#to-pushstate-data')
.click()
.waitForElementByCss('#pushstate-data')
.text()
).toBe('PushState Data')
await browser
.elementByCss('#push-state')
.click()
.waitForElementByCss('#state-updated')
.elementByCss('#get-latest')
.click()
await check(
() => browser.elementByCss('#my-data').text(),
`{"foo":"bar"}`
)
})
it('should support setting a different pathname reflected on usePathname', async () => {
const browser = await next.browser('/a')
expect(
await browser
.elementByCss('#to-pushstate-new-pathname')
.click()
.waitForElementByCss('#pushstate-pathname')
.text()
).toBe('PushState Pathname')
await browser.elementByCss('#push-pathname').click()
// Check usePathname value is the new pathname
await check(
() => browser.elementByCss('#my-data').text(),
'/my-non-existent-path'
)
// Check current url is the new pathname
expect(await browser.url()).toBe(`${next.url}/my-non-existent-path`)
})
it('should support setting a different searchParam reflected on useSearchParams', async () => {
const browser = await next.browser('/a')
expect(
await browser
.elementByCss('#to-pushstate-new-searchparams')
.click()
.waitForElementByCss('#pushstate-searchparams')
.text()
).toBe('PushState SearchParams')
await browser.elementByCss('#push-searchparams').click()
// Check useSearchParams value is the new searchparam
await check(() => browser.elementByCss('#my-data').text(), 'foo')
// Check current url is the new searchparams
expect(await browser.url()).toBe(
`${next.url}/pushstate-new-searchparams?query=foo`
)
// Same cycle a second time
await browser.elementByCss('#push-searchparams').click()
// Check useSearchParams value is the new searchparam
await check(() => browser.elementByCss('#my-data').text(), 'foo-added')
// Check current url is the new searchparams
expect(await browser.url()).toBe(
`${next.url}/pushstate-new-searchparams?query=foo-added`
)
})
})
describe('replaceState', () => {
it('should support setting data', async () => {
const browser = await next.browser('/a')
expect(
await browser
.elementByCss('#to-replacestate-data')
.click()
.waitForElementByCss('#replacestate-data')
.text()
).toBe('ReplaceState Data')
await browser
.elementByCss('#replace-state')
.click()
.waitForElementByCss('#state-updated')
.elementByCss('#get-latest')
.click()
await check(
() => browser.elementByCss('#my-data').text(),
`{"foo":"bar"}`
)
})
it('should support setting a different pathname reflected on usePathname', async () => {
const browser = await next.browser('/a')
expect(
await browser
.elementByCss('#to-replacestate-new-pathname')
.click()
.waitForElementByCss('#replacestate-pathname')
.text()
).toBe('ReplaceState Pathname')
await browser.elementByCss('#replace-pathname').click()
// Check usePathname value is the new pathname
await check(
() => browser.elementByCss('#my-data').text(),
'/my-non-existent-path'
)
// Check current url is the new pathname
expect(await browser.url()).toBe(`${next.url}/my-non-existent-path`)
})
it('should support setting a different searchParam reflected on useSearchParams', async () => {
const browser = await next.browser('/a')
expect(
await browser
.elementByCss('#to-replacestate-new-searchparams')
.click()
.waitForElementByCss('#replacestate-searchparams')
.text()
).toBe('ReplaceState SearchParams')
await browser.elementByCss('#replace-searchparams').click()
// Check useSearchParams value is the new searchparam
await check(() => browser.elementByCss('#my-data').text(), 'foo')
// Check current url is the new searchparams
expect(await browser.url()).toBe(
`${next.url}/replacestate-new-searchparams?query=foo`
)
// Same cycle a second time
await browser.elementByCss('#replace-searchparams').click()
// Check useSearchParams value is the new searchparam
await check(() => browser.elementByCss('#my-data').text(), 'foo-added')
// Check current url is the new searchparams
expect(await browser.url()).toBe(
`${next.url}/replacestate-new-searchparams?query=foo-added`
)
})
})
describe('back and forward', () => {
describe('client-side navigation', () => {
it('should support setting a different pathname reflected on usePathname and then still support navigating back and forward', async () => {
const browser = await next.browser('/a')
expect(
await browser
.elementByCss('#to-pushstate-new-pathname')
.click()
.waitForElementByCss('#pushstate-pathname')
.text()
).toBe('PushState Pathname')
await browser.elementByCss('#push-pathname').click()
// Check usePathname value is the new pathname
await check(
() => browser.elementByCss('#my-data').text(),
'/my-non-existent-path'
)
// Check current url is the new pathname
expect(await browser.url()).toBe(`${next.url}/my-non-existent-path`)
// Navigate back
await browser.back()
// Check usePathname value is the old pathname
await check(
() => browser.elementByCss('#my-data').text(),
'/pushstate-new-pathname'
)
await browser.forward()
// Check usePathname value is the old pathname
await check(
() => browser.elementByCss('#my-data').text(),
'/my-non-existent-path'
)
})
})
// Browser navigation using `<a>` and such.
describe('mpa navigation', () => {
it('should support setting data and then still support navigating back and forward', async () => {
const browser = await next.browser('/a')
expect(
await browser
.elementByCss('#to-pushstate-data')
.click()
.waitForElementByCss('#pushstate-data')
.text()
).toBe('PushState Data')
await browser
.elementByCss('#push-state')
.click()
.waitForElementByCss('#state-updated')
.elementByCss('#get-latest')
.click()
await check(
() => browser.elementByCss('#my-data').text(),
`{"foo":"bar"}`
)
expect(
await browser
.elementByCss('#to-a-mpa')
.click()
.waitForElementByCss('#page-a')
.text()
).toBe('Page A')
// Navigate back
await browser.back()
// Check usePathname value is the old pathname
await check(
() => browser.elementByCss('#my-data').text(),
`{"foo":"bar"}`
)
await browser.forward()
await check(
() =>
browser
.elementByCss('#to-a-mpa')
.click()
.waitForElementByCss('#page-a')
.text(),
'Page A'
)
})
})
})
}
)