-
Notifications
You must be signed in to change notification settings - Fork 31.1k
Add experimental support for history.pushState and history.replaceState #58335
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
174861b
db4c408
e5a8bd2
2486105
b78d35c
af92394
c5284f2
8d85bb2
1498a8c
2b2ee73
d957ef9
69befda
e83926a
c95b795
c37e0fa
14f8317
358a380
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
timneutkens marked this conversation as resolved.
|
||
| : {}), | ||
| // 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @timneutkens I believe |
||
| } | ||
| 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), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue: This should be based on One can set search params on the current page (and maintain router state) via suggestion: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I created issue #56636 some time ago. I spotted this experimental feature in the 14.0.3 release notes and thought it could fix it. However, when I try it, I just get
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| 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) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why don't write all of the logic in that file as
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Because it's downleveled in a way that is very code-size intensive
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In React we just write A function might compress better in some cases (although not always) but the way to think code size is that it’s mostly a heuristic for how long it takes for the VM to parse and compile (that’s why byte for byte JS is way more expensive than, say, an image); in this case the inlined form is very simple.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What I was referring to is that optional chaining and |
||
| ? 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, | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.