-
Notifications
You must be signed in to change notification settings - Fork 30.4k
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
Merged
kodiakhq
merged 17 commits into
canary
from
11-11-Add_support_for_history.pushState_and_history.replaceState
Nov 13, 2023
Merged
Changes from 5 commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
174861b
Add support for history.pushState and history.replaceState
timneutkens db4c408
Add experimental flag for these changes: experimental.windowHistorySu…
timneutkens e5a8bd2
Add comment explaining the override
timneutkens 2486105
Add comment explaining check for canonicalUrl
timneutkens b78d35c
Add marker for custom data push
timneutkens af92394
Move onPopState and applyUrlFromHistoryPushReplace into useEffect cal…
timneutkens c5284f2
Add updateHistory to pushRef
timneutkens 8d85bb2
Merge branch 'canary' of https://github.com/vercel/next.js into 11-11…
timneutkens 1498a8c
Fix type
timneutkens 2b2ee73
Skip updating history when updateHistory is false
timneutkens d957ef9
Set mutable.updateHistory in all reducers
timneutkens 69befda
Add updateHistory: false
timneutkens e83926a
Add preserveCustomHistoryState
timneutkens c95b795
Fix tests
timneutkens c37e0fa
Handle preserveCustomHistoryState correctly
timneutkens 14f8317
Remove __PRIVATE_NEXTJS_INTERNALS_CUSTOM_DATA as it's not used
timneutkens 358a380
Merge branch 'canary' into 11-11-Add_support_for_history.pushState_an…
timneutkens File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 = { | ||
| // Keep existing history state to support navigation through e.g. pushState / replaceState outside of Next.js. | ||
| ...(process.env.__NEXT_WINDOW_HISTORY_SUPPORT | ||
| ? window.history.state | ||
timneutkens marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| : undefined), | ||
| // 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, | ||
| }) | ||
| } | ||
|
|
||
|
|
@@ -441,20 +488,83 @@ function Router({ | |
| dispatch({ | ||
| type: ACTION_RESTORE, | ||
| url: new URL(window.location.href), | ||
| tree: state.tree, | ||
| tree: state.__PRIVATE_NEXTJS_INTERNALS_TREE, | ||
| }) | ||
| }) | ||
| }, | ||
| [dispatch] | ||
| ) | ||
|
|
||
| // 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 = useCallback( | ||
timneutkens marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| (url: string | URL) => { | ||
| startTransition(() => { | ||
| dispatch({ | ||
| type: ACTION_RESTORE, | ||
| url: new URL(url ?? window.location.href), | ||
| tree: window.history.state.__PRIVATE_NEXTJS_INTERNALS_TREE, | ||
| }) | ||
| }) | ||
| }, | ||
| [dispatch] | ||
| ) | ||
|
|
||
| // Register popstate event to call onPopstate. | ||
| useEffect(() => { | ||
| if (process.env.__NEXT_WINDOW_HISTORY_SUPPORT) { | ||
| 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) | ||
| data.__PRIVATE_NEXTJS_INTERNALS_CUSTOM_DATA = true | ||
|
|
||
| if (url) { | ||
| 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) | ||
| data.__PRIVATE_NEXTJS_INTERNALS_CUSTOM_DATA = true | ||
|
|
||
| if (url) { | ||
| applyUrlFromHistoryPushReplace(url) | ||
| } | ||
| return originalReplaceState(data, _unused, url) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // 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]) | ||
| }, [onPopState, applyUrlFromHistoryPushReplace]) | ||
|
|
||
| const { cache, tree, nextUrl, focusAndScrollRef } = | ||
| useUnwrapState(reducerState) | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| export default function Page() { | ||
| return ( | ||
| <> | ||
| <h1 id="page-a">Page A</h1> | ||
| </> | ||
| ) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| export default function Page() { | ||
| return ( | ||
| <> | ||
| <h1 id="page-b">Page B</h1> | ||
| </> | ||
| ) | ||
| } |
7 changes: 7 additions & 0 deletions
7
test/e2e/app-dir/shallow-routing/app/(shallow)/dynamic/[id]/page.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| export default function Page({ params }) { | ||
| return ( | ||
| <> | ||
| <h1 id={`page-id-${params.id}`}>Page ID: {params.id}</h1> | ||
| </> | ||
| ) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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} | ||
| </> | ||
| ) | ||
| } |
34 changes: 34 additions & 0 deletions
34
test/e2e/app-dir/shallow-routing/app/(shallow)/pushstate-data/page.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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> | ||
| </> | ||
| ) | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.