Skip to content

Commit

Permalink
make ACTION_RESTORE resilient to a missing tree
Browse files Browse the repository at this point in the history
  • Loading branch information
ztanner committed Feb 15, 2024
1 parent ea65cb6 commit 441ead6
Show file tree
Hide file tree
Showing 5 changed files with 69 additions and 16 deletions.
15 changes: 6 additions & 9 deletions packages/next/src/client/components/app-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -511,19 +511,14 @@ function Router({
url: string | URL | null | undefined
) => {
const href = window.location.href
const urlToRestore = new URL(url ?? href, href)
if (!window.history.state?.__PRIVATE_NEXTJS_INTERNALS_TREE) {
// we cannot safely recover from a missing tree -- we need trigger an MPA navigation
// to restore the router history to the correct state.
window.location.href = urlToRestore.pathname
return
}
const tree: FlightRouterState | undefined =
window.history.state?.__PRIVATE_NEXTJS_INTERNALS_TREE

startTransition(() => {
dispatch({
type: ACTION_RESTORE,
url: urlToRestore,
tree: window.history.state.__PRIVATE_NEXTJS_INTERNALS_TREE,
url: new URL(url ?? href, href),
tree,
})
})
}
Expand All @@ -542,6 +537,8 @@ function Router({
if (data?.__NA || data?._N) {
return originalPushState(data, _unused, url)
}

console.log()
data = copyNextJsInternalHistoryState(data)

if (url) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,21 @@ export function restoreReducer(
): ReducerState {
const { url, tree } = action
const href = createHrefFromUrl(url)
// This action is used to restore the router state from the history state.
// However, it's possible that the history state no longer contains the `FlightRouterState`.
// We will copy over the internal state on pushState/replaceState events, but if a history entry
// occurred before hydration, or if the user navigated to a hash using a regular anchor link,
// the history state will not contain the `FlightRouterState`.
// In this case, we'll continue to use the existing tree so the router doesn't get into an invalid state.
const treeToRestore = tree || state.tree

const oldCache = state.cache
const newCache = process.env.__NEXT_PPR
? // When PPR is enabled, we update the cache to drop the prefetch
// data for any segment whose dynamic data was already received. This
// prevents an unnecessary flash back to PPR state during a
// back/forward navigation.
updateCacheNodeOnPopstateRestoration(oldCache, tree)
updateCacheNodeOnPopstateRestoration(oldCache, treeToRestore)
: oldCache

return {
Expand All @@ -37,7 +44,7 @@ export function restoreReducer(
cache: newCache,
prefetchCache: state.prefetchCache,
// Restore provided tree
tree: tree,
nextUrl: extractPathFromFlightRouterState(tree) ?? url.pathname,
tree: treeToRestore,
nextUrl: extractPathFromFlightRouterState(treeToRestore) ?? url.pathname,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,15 +114,17 @@ export interface NavigateAction {

/**
* Restore applies the provided router state.
* - Only used for `popstate` (back/forward navigation) where a known router state has to be applied.
* - Router state is applied as-is from the history state.
* - Used for `popstate` (back/forward navigation) where a known router state has to be applied.
* - Also used when syncing the router state with `pushState`/`replaceState` calls.
* - Router state is applied as-is from the history state, if available.
* - If the history state does not contain the router state, the existing router state is used.
* - If any cache node is missing it will be fetched in layout-router during rendering and the server-patch case.
* - If existing cache nodes match these are used.
*/
export interface RestoreAction {
type: typeof ACTION_RESTORE
url: URL
tree: FlightRouterState
tree?: FlightRouterState
}

/**
Expand Down
7 changes: 6 additions & 1 deletion test/e2e/app-dir/shallow-routing/app/(shallow)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ export default function ShallowLayout({ children }) {
<>
<h1>Shallow Routing</h1>
<div>
<div>
<a href="#content" id="hash-navigation">
Hash Navigation (non-Link)
</a>
</div>
<div>
<Link href="/a" id="to-a">
To A
Expand Down Expand Up @@ -85,7 +90,7 @@ export default function ShallowLayout({ children }) {
</Link>
</div>
</div>
{children}
<div id="content">{children}</div>
</>
)
}
42 changes: 42 additions & 0 deletions test/e2e/app-dir/shallow-routing/shallow-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,48 @@ createNextDescribe(
'Page A'
)
})

it('should support hash navigations while continuing to work for pushState/replaceState APIs', async () => {
const browser = await next.browser('/a')
expect(
await browser
.elementByCss('#to-pushstate-string-url')
.click()
.waitForElementByCss('#pushstate-string-url')
.text()
).toBe('PushState String Url')

await browser.elementByCss('#hash-navigation').click()

// Check current url contains the hash
expect(await browser.url()).toBe(
`${next.url}/pushstate-string-url#content`
)

await browser.elementByCss('#push-string-url').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-string-url?query=foo`
)

// Same cycle a second time
await browser.elementByCss('#push-string-url').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-string-url?query=foo-added`
)
})
})
})
}
Expand Down

0 comments on commit 441ead6

Please sign in to comment.