diff --git a/packages/next/src/build/webpack/plugins/define-env-plugin.ts b/packages/next/src/build/webpack/plugins/define-env-plugin.ts index 2cba3329788c2..2217f9a2af436 100644 --- a/packages/next/src/build/webpack/plugins/define-env-plugin.ts +++ b/packages/next/src/build/webpack/plugins/define-env-plugin.ts @@ -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 ), diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx index 7eed516fb8528..13c6811c20017 100644 --- a/packages/next/src/client/components/app-router.tsx +++ b/packages/next/src/client/components/app-router.tsx @@ -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): 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) diff --git a/packages/next/src/client/components/router-reducer/create-initial-router-state.test.tsx b/packages/next/src/client/components/router-reducer/create-initial-router-state.test.tsx index 036944640cadf..c605eb281802c 100644 --- a/packages/next/src/client/components/router-reducer/create-initial-router-state.test.tsx +++ b/packages/next/src/client/components/router-reducer/create-initial-router-state.test.tsx @@ -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, diff --git a/packages/next/src/client/components/router-reducer/create-initial-router-state.ts b/packages/next/src/client/components/router-reducer/create-initial-router-state.ts index 94fdabb9b577a..c112997f72ae6 100644 --- a/packages/next/src/client/components/router-reducer/create-initial-router-state.ts +++ b/packages/next/src/client/components/router-reducer/create-initial-router-state.ts @@ -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, diff --git a/packages/next/src/client/components/router-reducer/handle-mutable.ts b/packages/next/src/client/components/router-reducer/handle-mutable.ts index 11d434226ee60..d81617d27853c 100644 --- a/packages/next/src/client/components/router-reducer/handle-mutable.ts +++ b/packages/next/src/client/components/router-reducer/handle-mutable.ts @@ -5,6 +5,10 @@ import type { ReducerState, } from './router-reducer-types' +function isNotUndefined(value: T): value is Exclude { + 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, } } diff --git a/packages/next/src/client/components/router-reducer/reducers/fast-refresh-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/fast-refresh-reducer.ts index ef4bdc59099b9..e02151925823d 100644 --- a/packages/next/src/client/components/router-reducer/reducers/fast-refresh-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/fast-refresh-reducer.ts @@ -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. diff --git a/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.test.tsx b/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.test.tsx index 397d180a3a017..18183b68c9632 100644 --- a/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.test.tsx +++ b/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.test.tsx @@ -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": [ "", diff --git a/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts index e200066089666..078180600803b 100644 --- a/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts @@ -127,6 +127,8 @@ export function navigateReducer( return handleMutable(state, mutable) } + mutable.preserveCustomHistoryState = false + if (isExternalUrl) { return handleExternalUrl(state, mutable, url.toString(), pendingPush) } diff --git a/packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.test.tsx b/packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.test.tsx index 1aa7cd2d6a913..430aa83271f30 100644 --- a/packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.test.tsx +++ b/packages/next/src/client/components/router-reducer/reducers/prefetch-reducer.test.tsx @@ -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, diff --git a/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.test.tsx b/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.test.tsx index 2d11f63d5f5ad..6865f9d4cdb68 100644 --- a/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.test.tsx +++ b/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.test.tsx @@ -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, diff --git a/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts index 793d79bbda772..352fd01722437 100644 --- a/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts @@ -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. diff --git a/packages/next/src/client/components/router-reducer/reducers/restore-reducer.test.tsx b/packages/next/src/client/components/router-reducer/reducers/restore-reducer.test.tsx index 8cca3d7a9701c..3a890266ba962 100644 --- a/packages/next/src/client/components/router-reducer/reducers/restore-reducer.test.tsx +++ b/packages/next/src/client/components/router-reducer/reducers/restore-reducer.test.tsx @@ -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, diff --git a/packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts index 9b0ff103c94a6..06e453c6edfcc 100644 --- a/packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts @@ -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, diff --git a/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts index 4409103f3f5d3..e8fc91c2cef95 100644 --- a/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts @@ -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. diff --git a/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.test.tsx b/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.test.tsx index 88158c2bb595a..4814e9a3b4e0d 100644 --- a/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.test.tsx +++ b/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.test.tsx @@ -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": [ "", diff --git a/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.ts index d0697ea16084c..2c9200793b601 100644 --- a/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.ts @@ -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( diff --git a/packages/next/src/client/components/router-reducer/router-reducer-types.ts b/packages/next/src/client/components/router-reducer/router-reducer-types.ts index e5a794a8825b4..505bc53e67cc8 100644 --- a/packages/next/src/client/components/router-reducer/router-reducer-types.ts +++ b/packages/next/src/client/components/router-reducer/router-reducer-types.ts @@ -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 = { diff --git a/packages/next/src/client/components/use-reducer-with-devtools.ts b/packages/next/src/client/components/use-reducer-with-devtools.ts index eb1157f5cb7dd..1db2769684db3 100644 --- a/packages/next/src/client/components/use-reducer-with-devtools.ts +++ b/packages/next/src/client/components/use-reducer-with-devtools.ts @@ -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) diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index 9e702a4a7d9c0..21a7ed6450731 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -226,6 +226,7 @@ export const configSchema: zod.ZodType = 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(), diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 462f3c6ab9f15..b3db542eede1f 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -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, diff --git a/test/e2e/app-dir/shallow-routing/app/(shallow)/a/page.tsx b/test/e2e/app-dir/shallow-routing/app/(shallow)/a/page.tsx new file mode 100644 index 0000000000000..ea6ce9dfe258a --- /dev/null +++ b/test/e2e/app-dir/shallow-routing/app/(shallow)/a/page.tsx @@ -0,0 +1,7 @@ +export default function Page() { + return ( + <> +

Page A

+ + ) +} diff --git a/test/e2e/app-dir/shallow-routing/app/(shallow)/b/page.tsx b/test/e2e/app-dir/shallow-routing/app/(shallow)/b/page.tsx new file mode 100644 index 0000000000000..6e5c2e98c0ee7 --- /dev/null +++ b/test/e2e/app-dir/shallow-routing/app/(shallow)/b/page.tsx @@ -0,0 +1,7 @@ +export default function Page() { + return ( + <> +

Page B

+ + ) +} diff --git a/test/e2e/app-dir/shallow-routing/app/(shallow)/dynamic/[id]/page.tsx b/test/e2e/app-dir/shallow-routing/app/(shallow)/dynamic/[id]/page.tsx new file mode 100644 index 0000000000000..6d94f0bb1bcdd --- /dev/null +++ b/test/e2e/app-dir/shallow-routing/app/(shallow)/dynamic/[id]/page.tsx @@ -0,0 +1,7 @@ +export default function Page({ params }) { + return ( + <> +

Page ID: {params.id}

+ + ) +} diff --git a/test/e2e/app-dir/shallow-routing/app/(shallow)/layout.tsx b/test/e2e/app-dir/shallow-routing/app/(shallow)/layout.tsx new file mode 100644 index 0000000000000..4d985168de788 --- /dev/null +++ b/test/e2e/app-dir/shallow-routing/app/(shallow)/layout.tsx @@ -0,0 +1,81 @@ +import Link from 'next/link' + +export default function ShallowLayout({ children }) { + return ( + <> +

Shallow Routing

+
+
+ + To A + +
+ +
+ + To B + +
+ +
+ + To Dynamic 1 + +
+
+ + To Dynamic 2 + +
+
+ + To PushState Data + +
+
+ + To PushState new SearchParams + +
+
+ + To PushState new pathname + +
+
+ + To ReplaceState Data + +
+
+ + To ReplaceState new SearchParams + +
+
+ + To ReplaceState new pathname + +
+
+ {children} + + ) +} diff --git a/test/e2e/app-dir/shallow-routing/app/(shallow)/pushstate-data/page.tsx b/test/e2e/app-dir/shallow-routing/app/(shallow)/pushstate-data/page.tsx new file mode 100644 index 0000000000000..1cb228916cdea --- /dev/null +++ b/test/e2e/app-dir/shallow-routing/app/(shallow)/pushstate-data/page.tsx @@ -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 ( + <> +

PushState Data

+ {updated ?
: null} +
{JSON.stringify(data)}
+ + + + ) +} diff --git a/test/e2e/app-dir/shallow-routing/app/(shallow)/pushstate-new-pathname/page.tsx b/test/e2e/app-dir/shallow-routing/app/(shallow)/pushstate-new-pathname/page.tsx new file mode 100644 index 0000000000000..20f50c8bfd456 --- /dev/null +++ b/test/e2e/app-dir/shallow-routing/app/(shallow)/pushstate-new-pathname/page.tsx @@ -0,0 +1,22 @@ +'use client' +import { usePathname } from 'next/navigation' + +export default function Page() { + const pathname = usePathname() + return ( + <> +

PushState Pathname

+
{pathname}
+ + + ) +} diff --git a/test/e2e/app-dir/shallow-routing/app/(shallow)/pushstate-new-searchparams/page.tsx b/test/e2e/app-dir/shallow-routing/app/(shallow)/pushstate-new-searchparams/page.tsx new file mode 100644 index 0000000000000..4c1435c94d102 --- /dev/null +++ b/test/e2e/app-dir/shallow-routing/app/(shallow)/pushstate-new-searchparams/page.tsx @@ -0,0 +1,26 @@ +'use client' +import { useSearchParams } from 'next/navigation' + +export default function Page() { + const searchParams = useSearchParams() + return ( + <> +

PushState SearchParams

+
{searchParams.get('query')}
+ + + ) +} diff --git a/test/e2e/app-dir/shallow-routing/app/(shallow)/replacestate-data/page.tsx b/test/e2e/app-dir/shallow-routing/app/(shallow)/replacestate-data/page.tsx new file mode 100644 index 0000000000000..1ec1d0b6ce765 --- /dev/null +++ b/test/e2e/app-dir/shallow-routing/app/(shallow)/replacestate-data/page.tsx @@ -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 ( + <> +

ReplaceState Data

+ {updated ?
: null} +
{JSON.stringify(data)}
+ + + + ) +} diff --git a/test/e2e/app-dir/shallow-routing/app/(shallow)/replacestate-new-pathname/page.tsx b/test/e2e/app-dir/shallow-routing/app/(shallow)/replacestate-new-pathname/page.tsx new file mode 100644 index 0000000000000..9b4779974f54a --- /dev/null +++ b/test/e2e/app-dir/shallow-routing/app/(shallow)/replacestate-new-pathname/page.tsx @@ -0,0 +1,22 @@ +'use client' +import { usePathname } from 'next/navigation' + +export default function Page() { + const pathname = usePathname() + return ( + <> +

ReplaceState Pathname

+
{pathname}
+ + + ) +} diff --git a/test/e2e/app-dir/shallow-routing/app/(shallow)/replacestate-new-searchparams/page.tsx b/test/e2e/app-dir/shallow-routing/app/(shallow)/replacestate-new-searchparams/page.tsx new file mode 100644 index 0000000000000..fe92608bc8a09 --- /dev/null +++ b/test/e2e/app-dir/shallow-routing/app/(shallow)/replacestate-new-searchparams/page.tsx @@ -0,0 +1,26 @@ +'use client' +import { useSearchParams } from 'next/navigation' + +export default function Page() { + const searchParams = useSearchParams() + return ( + <> +

ReplaceState SearchParams

+
{searchParams.get('query')}
+ + + ) +} diff --git a/test/e2e/app-dir/shallow-routing/app/layout.tsx b/test/e2e/app-dir/shallow-routing/app/layout.tsx new file mode 100644 index 0000000000000..e7077399c03ce --- /dev/null +++ b/test/e2e/app-dir/shallow-routing/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/shallow-routing/app/page.tsx b/test/e2e/app-dir/shallow-routing/app/page.tsx new file mode 100644 index 0000000000000..7a3c7e8c478c6 --- /dev/null +++ b/test/e2e/app-dir/shallow-routing/app/page.tsx @@ -0,0 +1,7 @@ +export default function Page() { + return ( + <> +

Shallow Routing

+ + ) +} diff --git a/test/e2e/app-dir/shallow-routing/next.config.js b/test/e2e/app-dir/shallow-routing/next.config.js new file mode 100644 index 0000000000000..37432f95db415 --- /dev/null +++ b/test/e2e/app-dir/shallow-routing/next.config.js @@ -0,0 +1,10 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + windowHistorySupport: true, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/shallow-routing/shallow-routing.test.ts b/test/e2e/app-dir/shallow-routing/shallow-routing.test.ts new file mode 100644 index 0000000000000..66b349c07fe35 --- /dev/null +++ b/test/e2e/app-dir/shallow-routing/shallow-routing.test.ts @@ -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 `` 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' + ) + }) + }) + }) + } +)