Skip to content

Commit f435ea9

Browse files
authored
Merge branch 'canary' into fix-parallel-routes-with-catch-all
2 parents ca11c1f + 797fecb commit f435ea9

34 files changed

+767
-49
lines changed

packages/next/src/build/webpack/plugins/define-env-plugin.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ export function getDefineEnv({
9595
isEdgeServer ? 'edge' : isNodeServer ? 'nodejs' : ''
9696
),
9797
'process.env.NEXT_MINIMAL': JSON.stringify(''),
98+
'process.env.__NEXT_WINDOW_HISTORY_SUPPORT': JSON.stringify(
99+
config.experimental.windowHistorySupport
100+
),
98101
'process.env.__NEXT_ACTIONS_DEPLOYMENT_ID': JSON.stringify(
99102
config.experimental.useDeploymentIdServerActions
100103
),

packages/next/src/client/components/app-router.tsx

Lines changed: 126 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
PrefetchKind,
3636
} from './router-reducer/router-reducer-types'
3737
import type {
38+
PushRef,
3839
ReducerActions,
3940
RouterChangeByServerResponse,
4041
RouterNavigate,
@@ -108,24 +109,44 @@ function isExternalURL(url: URL) {
108109
return url.origin !== window.location.origin
109110
}
110111

111-
function HistoryUpdater({ tree, pushRef, canonicalUrl, sync }: any) {
112+
function HistoryUpdater({
113+
tree,
114+
pushRef,
115+
canonicalUrl,
116+
sync,
117+
}: {
118+
tree: FlightRouterState
119+
pushRef: PushRef
120+
canonicalUrl: string
121+
sync: () => void
122+
}) {
112123
useInsertionEffect(() => {
113-
// Identifier is shortened intentionally.
114-
// __NA is used to identify if the history entry can be handled by the app-router.
115-
// __N is used to identify if the history entry can be handled by the old router.
116124
const historyState = {
125+
...(process.env.__NEXT_WINDOW_HISTORY_SUPPORT &&
126+
pushRef.preserveCustomHistoryState
127+
? window.history.state
128+
: {}),
129+
// Identifier is shortened intentionally.
130+
// __NA is used to identify if the history entry can be handled by the app-router.
131+
// __N is used to identify if the history entry can be handled by the old router.
117132
__NA: true,
118-
tree,
133+
__PRIVATE_NEXTJS_INTERNALS_TREE: tree,
119134
}
120135
if (
121136
pushRef.pendingPush &&
137+
// Skip pushing an additional history entry if the canonicalUrl is the same as the current url.
138+
// This mirrors the browser behavior for normal navigation.
122139
createHrefFromUrl(new URL(window.location.href)) !== canonicalUrl
123140
) {
124141
// This intentionally mutates React state, pushRef is overwritten to ensure additional push/replace calls do not trigger an additional history entry.
125142
pushRef.pendingPush = false
126-
window.history.pushState(historyState, '', canonicalUrl)
143+
if (originalPushState) {
144+
originalPushState(historyState, '', canonicalUrl)
145+
}
127146
} else {
128-
window.history.replaceState(historyState, '', canonicalUrl)
147+
if (originalReplaceState) {
148+
originalReplaceState(historyState, '', canonicalUrl)
149+
}
129150
}
130151
sync()
131152
}, [tree, pushRef, canonicalUrl, sync])
@@ -204,6 +225,28 @@ function useNavigate(dispatch: React.Dispatch<ReducerActions>): RouterNavigate {
204225
)
205226
}
206227

228+
const originalPushState =
229+
typeof window !== 'undefined'
230+
? window.history.pushState.bind(window.history)
231+
: null
232+
const originalReplaceState =
233+
typeof window !== 'undefined'
234+
? window.history.replaceState.bind(window.history)
235+
: null
236+
237+
function copyNextJsInternalHistoryState(data: any) {
238+
const currentState = window.history.state
239+
const __NA = currentState?.__NA
240+
if (__NA) {
241+
data.__NA = __NA
242+
}
243+
const __PRIVATE_NEXTJS_INTERNALS_TREE =
244+
currentState?.__PRIVATE_NEXTJS_INTERNALS_TREE
245+
if (__PRIVATE_NEXTJS_INTERNALS_TREE) {
246+
data.__PRIVATE_NEXTJS_INTERNALS_TREE = __PRIVATE_NEXTJS_INTERNALS_TREE
247+
}
248+
}
249+
207250
/**
208251
* The global router that wraps the application components.
209252
*/
@@ -371,12 +414,16 @@ function Router({
371414
// would trigger the mpa navigation logic again from the lines below.
372415
// This will restore the router to the initial state in the event that the app is restored from bfcache.
373416
function handlePageShow(event: PageTransitionEvent) {
374-
if (!event.persisted || !window.history.state?.tree) return
417+
if (
418+
!event.persisted ||
419+
!window.history.state?.__PRIVATE_NEXTJS_INTERNALS_TREE
420+
)
421+
return
375422

376423
dispatch({
377424
type: ACTION_RESTORE,
378425
url: new URL(window.location.href),
379-
tree: window.history.state.tree,
426+
tree: window.history.state.__PRIVATE_NEXTJS_INTERNALS_TREE,
380427
})
381428
}
382429

@@ -416,13 +463,66 @@ function Router({
416463
use(createInfinitePromise())
417464
}
418465

419-
/**
420-
* Handle popstate event, this is used to handle back/forward in the browser.
421-
* By default dispatches ACTION_RESTORE, however if the history entry was not pushed/replaced by app-router it will reload the page.
422-
* That case can happen when the old router injected the history entry.
423-
*/
424-
const onPopState = useCallback(
425-
({ state }: PopStateEvent) => {
466+
useEffect(() => {
467+
if (process.env.__NEXT_WINDOW_HISTORY_SUPPORT) {
468+
// 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.
469+
const applyUrlFromHistoryPushReplace = (
470+
url: string | URL | null | undefined
471+
) => {
472+
startTransition(() => {
473+
dispatch({
474+
type: ACTION_RESTORE,
475+
url: new URL(url ?? window.location.href),
476+
tree: window.history.state.__PRIVATE_NEXTJS_INTERNALS_TREE,
477+
})
478+
})
479+
}
480+
481+
if (originalPushState) {
482+
/**
483+
* Patch pushState to ensure external changes to the history are reflected in the Next.js Router.
484+
* Ensures Next.js internal history state is copied to the new history entry.
485+
* Ensures usePathname and useSearchParams hold the newly provided url.
486+
*/
487+
window.history.pushState = function pushState(
488+
data: any,
489+
_unused: string,
490+
url?: string | URL | null
491+
): void {
492+
copyNextJsInternalHistoryState(data)
493+
494+
applyUrlFromHistoryPushReplace(url)
495+
496+
return originalPushState(data, _unused, url)
497+
}
498+
}
499+
if (originalReplaceState) {
500+
/**
501+
* Patch replaceState to ensure external changes to the history are reflected in the Next.js Router.
502+
* Ensures Next.js internal history state is copied to the new history entry.
503+
* Ensures usePathname and useSearchParams hold the newly provided url.
504+
*/
505+
window.history.replaceState = function replaceState(
506+
data: any,
507+
_unused: string,
508+
url?: string | URL | null
509+
): void {
510+
copyNextJsInternalHistoryState(data)
511+
512+
if (url) {
513+
applyUrlFromHistoryPushReplace(url)
514+
}
515+
return originalReplaceState(data, _unused, url)
516+
}
517+
}
518+
}
519+
520+
/**
521+
* Handle popstate event, this is used to handle back/forward in the browser.
522+
* By default dispatches ACTION_RESTORE, however if the history entry was not pushed/replaced by app-router it will reload the page.
523+
* That case can happen when the old router injected the history entry.
524+
*/
525+
const onPopState = ({ state }: PopStateEvent) => {
426526
if (!state) {
427527
// TODO-APP: this case only happens when pushState/replaceState was called outside of Next.js. It should probably reload the page in this case.
428528
return
@@ -441,20 +541,23 @@ function Router({
441541
dispatch({
442542
type: ACTION_RESTORE,
443543
url: new URL(window.location.href),
444-
tree: state.tree,
544+
tree: state.__PRIVATE_NEXTJS_INTERNALS_TREE,
445545
})
446546
})
447-
},
448-
[dispatch]
449-
)
547+
}
450548

451-
// Register popstate event to call onPopstate.
452-
useEffect(() => {
549+
// Register popstate event to call onPopstate.
453550
window.addEventListener('popstate', onPopState)
454551
return () => {
552+
if (originalPushState) {
553+
window.history.pushState = originalPushState
554+
}
555+
if (originalReplaceState) {
556+
window.history.replaceState = originalReplaceState
557+
}
455558
window.removeEventListener('popstate', onPopState)
456559
}
457-
}, [onPopState])
560+
}, [dispatch])
458561

459562
const { cache, tree, nextUrl, focusAndScrollRef } =
460563
useUnwrapState(reducerState)

packages/next/src/client/components/router-reducer/create-initial-router-state.test.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,11 @@ describe('createInitialRouterState', () => {
9898
tree: initialTree,
9999
canonicalUrl: initialCanonicalUrl,
100100
prefetchCache: new Map(),
101-
pushRef: { pendingPush: false, mpaNavigation: false },
101+
pushRef: {
102+
pendingPush: false,
103+
mpaNavigation: false,
104+
preserveCustomHistoryState: true,
105+
},
102106
focusAndScrollRef: {
103107
apply: false,
104108
onlyHashChange: false,

packages/next/src/client/components/router-reducer/create-initial-router-state.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,13 @@ export function createInitialRouterState({
4646
tree: initialTree,
4747
cache,
4848
prefetchCache: new Map(),
49-
pushRef: { pendingPush: false, mpaNavigation: false },
49+
pushRef: {
50+
pendingPush: false,
51+
mpaNavigation: false,
52+
// First render needs to preserve the previous window.history.state
53+
// to avoid it being overwritten on navigation back/forward with MPA Navigation.
54+
preserveCustomHistoryState: true,
55+
},
5056
focusAndScrollRef: {
5157
apply: false,
5258
onlyHashChange: false,

packages/next/src/client/components/router-reducer/handle-mutable.ts

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import type {
55
ReducerState,
66
} from './router-reducer-types'
77

8+
function isNotUndefined<T>(value: T): value is Exclude<T, undefined> {
9+
return typeof value !== 'undefined'
10+
}
11+
812
export function handleMutable(
913
state: ReadonlyReducerState,
1014
mutable: Mutable
@@ -15,26 +19,28 @@ export function handleMutable(
1519
return {
1620
buildId: state.buildId,
1721
// Set href.
18-
canonicalUrl:
19-
mutable.canonicalUrl != null
20-
? mutable.canonicalUrl === state.canonicalUrl
21-
? state.canonicalUrl
22-
: mutable.canonicalUrl
23-
: state.canonicalUrl,
22+
canonicalUrl: isNotUndefined(mutable.canonicalUrl)
23+
? mutable.canonicalUrl === state.canonicalUrl
24+
? state.canonicalUrl
25+
: mutable.canonicalUrl
26+
: state.canonicalUrl,
2427
pushRef: {
25-
pendingPush:
26-
mutable.pendingPush != null
27-
? mutable.pendingPush
28-
: state.pushRef.pendingPush,
29-
mpaNavigation:
30-
mutable.mpaNavigation != null
31-
? mutable.mpaNavigation
32-
: state.pushRef.mpaNavigation,
28+
pendingPush: isNotUndefined(mutable.pendingPush)
29+
? mutable.pendingPush
30+
: state.pushRef.pendingPush,
31+
mpaNavigation: isNotUndefined(mutable.mpaNavigation)
32+
? mutable.mpaNavigation
33+
: state.pushRef.mpaNavigation,
34+
preserveCustomHistoryState: isNotUndefined(
35+
mutable.preserveCustomHistoryState
36+
)
37+
? mutable.preserveCustomHistoryState
38+
: state.pushRef.preserveCustomHistoryState,
3339
},
3440
// All navigation requires scroll and focus management to trigger.
3541
focusAndScrollRef: {
3642
apply: shouldScroll
37-
? mutable?.scrollableSegments !== undefined
43+
? isNotUndefined(mutable?.scrollableSegments)
3844
? true
3945
: state.focusAndScrollRef.apply
4046
: // If shouldScroll is false then we should not apply scroll and focus management.
@@ -63,11 +69,12 @@ export function handleMutable(
6369
? mutable.prefetchCache
6470
: state.prefetchCache,
6571
// Apply patched router state.
66-
tree: mutable.patchedTree !== undefined ? mutable.patchedTree : state.tree,
67-
nextUrl:
68-
mutable.patchedTree !== undefined
69-
? computeChangedPath(state.tree, mutable.patchedTree) ??
70-
state.canonicalUrl
71-
: state.nextUrl,
72+
tree: isNotUndefined(mutable.patchedTree)
73+
? mutable.patchedTree
74+
: state.tree,
75+
nextUrl: isNotUndefined(mutable.patchedTree)
76+
? computeChangedPath(state.tree, mutable.patchedTree) ??
77+
state.canonicalUrl
78+
: state.nextUrl,
7279
}
7380
}

packages/next/src/client/components/router-reducer/reducers/fast-refresh-reducer.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ function fastRefreshReducerImpl(
2626
return handleMutable(state, mutable)
2727
}
2828

29+
mutable.preserveCustomHistoryState = false
30+
2931
if (!cache.data) {
3032
// TODO-APP: verify that `href` is not an external url.
3133
// Fetch data from the root of the tree.

0 commit comments

Comments
 (0)