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:
parent
76da32e43f
commit
797fecb0c6
34 changed files with 767 additions and 49 deletions
|
@ -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
|
||||
),
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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": [
|
||||
"",
|
||||
|
|
|
@ -127,6 +127,8 @@ export function navigateReducer(
|
|||
return handleMutable(state, mutable)
|
||||
}
|
||||
|
||||
mutable.preserveCustomHistoryState = false
|
||||
|
||||
if (isExternalUrl) {
|
||||
return handleExternalUrl(state, mutable, url.toString(), pendingPush)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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": [
|
||||
"",
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<h1 id="page-a">Page A</h1>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<h1 id="page-b">Page B</h1>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
export default function Page({ params }) {
|
||||
return (
|
||||
<>
|
||||
<h1 id={`page-id-${params.id}`}>Page ID: {params.id}</h1>
|
||||
</>
|
||||
)
|
||||
}
|
81
test/e2e/app-dir/shallow-routing/app/(shallow)/layout.tsx
Normal file
81
test/e2e/app-dir/shallow-routing/app/(shallow)/layout.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
7
test/e2e/app-dir/shallow-routing/app/layout.tsx
Normal file
7
test/e2e/app-dir/shallow-routing/app/layout.tsx
Normal file
|
@ -0,0 +1,7 @@
|
|||
export default function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
7
test/e2e/app-dir/shallow-routing/app/page.tsx
Normal file
7
test/e2e/app-dir/shallow-routing/app/page.tsx
Normal file
|
@ -0,0 +1,7 @@
|
|||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<h1>Shallow Routing</h1>
|
||||
</>
|
||||
)
|
||||
}
|
10
test/e2e/app-dir/shallow-routing/next.config.js
Normal file
10
test/e2e/app-dir/shallow-routing/next.config.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* @type {import('next').NextConfig}
|
||||
*/
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
windowHistorySupport: true,
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
261
test/e2e/app-dir/shallow-routing/shallow-routing.test.ts
Normal file
261
test/e2e/app-dir/shallow-routing/shallow-routing.test.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
Loading…
Reference in a new issue