bfcacheId: Opt out of state preservation (#93633)

When `cacheComponents` is enabled, the App Router preserves state across
navigations by rendering inactive routes inside React `<Activity>`
boundaries.

As a default behavior, this is a huge convenience because it lets you
navigate between routes without resetting or losing ephemeral UI state
(scroll position, expand/collapse, in-progress form edits). Previously
the only way to do this was to explicitly track each state with an
external state manager, or hoist it to a parent component.

It's still the often the case that you should be explicitly tracking all
important UI state, anyway, so that it survives a hard refresh of the
app, or the browser window being accidentally closed. For example, forms
draft states should be persisted to a server-side database or local
storage engine. If you're already doing that, then it doesn't matter so
much whether client state is preserved via `<Activity>` boundaries or
not.

For the long tail of ephemeral state that is not tracked, Next.js's
philosophy is that it's a better UX default to preserve as much
emphemeral state as possible. It's easier to model the cases where you
_do_ want state to be reset on navigation as exceptions, compared to the
other way around.

However, this is a significant change compared to the pre-Cache
Components previous behavior of Next.js, and compared to other web
frameworks, and indeed the browser's own native bfcache (which
implements state restoration for history traversal navigations only, not
push/replace).

There's are also lots of existing codebases that may rely on the current
behavior, and may break subtly under these new semantics. Even if this
new default unlocks better UX patterns, we don't want to force everyone
to migrate all their code all at once.

So, this PR introduces a drop-in mechanism for opting out of state
preservation when navigating to a previously visited route.

The API is exposed as `useRouter().bfcacheId`. It's intended to be
passed to a React `key`:

  <form key={useRouter().bfcacheId}>

The id is contextual: read from a layout, you get the layout's id; read
from a page, you get the page's id. It's stable across back/forward
navigations, `router.refresh()`, server actions that call `refresh()`,
and search-param- or hash-only navigations — i.e., any time the
surrounding segment is preserved. It changes when the segment is freshly
created by a push or replace into a different route.

An important detail is that the previous id is restored during a back/
foward navigation. So state preservation will still work if you navigate
via the browser's back button.

Why add this to `useRouter()` instead of giving it its own hook? The
intent is communicate that `bfcacheId` is not considered an idiomatic
pattern — the recommended fix for "I want this state to reset on
navigation" is almost always something else: an explicit reset in a
submit handler, or a key derived from the underlying data (e.g., a draft
id from the server). `useRouter` is the hook where we expose low-level
APIs that are supported but are only recommended for advanced or
exceptional cases. (For example, instead of `router.push()`, you should
almost always use a `<Link>` component instead.)
This commit is contained in:
Andrew Clark
2026-05-11 20:01:42 -04:00
committed by GitHub
parent 63cd4233b4
commit 56d95137fd
17 changed files with 564 additions and 25 deletions
@@ -47,6 +47,7 @@ export default function Page() {
- `router.prefetch(href: string, options?: { onInvalidate?: () => void })`: [Prefetch](/docs/app/getting-started/linking-and-navigating#prefetching) the provided route for faster client-side transitions. The optional `onInvalidate` callback is called when the [prefetched data becomes stale](/docs/app/guides/prefetching#extending-or-ejecting-link).
- `router.back()`: Navigate back to the previous route in the browser’s history stack.
- `router.forward()`: Navigate forwards to the next page in the browser’s history stack.
- `router.bfcacheId`: An opaque string identifier scoped to the current route segment. It changes when the surrounding segment is freshly created by a push or replace navigation, and stays the same for back/forward navigations, `router.refresh()`, and search-param- or hash-only navigations. See [`bfcacheId`](#bfcacheid) below for details.
> **Good to know**:
>
@@ -156,6 +157,28 @@ export default function Page() {
}
```
### `bfcacheId`
`router.bfcacheId` is an opaque string identifier scoped to the current route segment. It changes when the surrounding segment is freshly created by a push or replace navigation, and stays the same for back/forward navigations, `router.refresh()`, and search-param- or hash-only navigations.
The recommended use is to pass it as a React `key` to opt out of state preservation on fresh navigations, while still restoring it during a back/forward navigation:
```tsx filename="app/example/page.tsx"
'use client'
import { useRouter } from 'next/navigation'
export default function Page() {
const { bfcacheId } = useRouter()
return <form key={bfcacheId}>{/* ... */}</form>
}
```
When `cacheComponents` is enabled, the App Router preserves Client Component state across navigations using React `<Activity>`. Keying a component on `bfcacheId` resets it on each fresh navigation while still preserving its state across browser back/forward navigations.
> **Good to know**:
> Instead of `bfcacheId`, prefer resetting state explicitly in an event handler (for example, `onSubmit`) or deriving a key from your data (for example, a draft id from the server). Use `bfcacheId` only as a last resort, like when migrating an existing codebase.
## Version History
| Version | Changes |
@@ -495,6 +495,9 @@ export const publicAppRouterInstance: AppRouterInstance = {
})
}
},
// Default value. Each route segment provides its own value at runtime. Refer
// to `useRouter()`.
bfcacheId: '0',
}
// Conditionally add experimental_gesturePush when gestureTransition is enabled
@@ -179,7 +179,29 @@ export function useRouter(): AppRouterInstance {
throw new Error('invariant expected app router to be mounted')
}
return router
// Read the bfcacheId of the closest CacheNode and merge it into the
// returned router instance. This is contextual: callers in a shared
// layout get the layout's id; callers in a leaf segment get the leaf's.
// The id is stored on the CacheNode as a number and materialized as a
// string here. The format mirrors React's `useId()` (e.g. `_r_0_`) with
// a `b` prefix, so the id can be safely concatenated with other keys
// without collision.
const layout = useContext(LayoutRouterContext)
const bfcacheIdNumber = layout?.parentCacheNode.bfcacheId ?? 0
return useMemo<AppRouterInstance>(
() => ({
back: router.back,
forward: router.forward,
refresh: router.refresh,
hmrRefresh: router.hmrRefresh,
push: router.push,
replace: router.replace,
prefetch: router.prefetch,
experimental_gesturePush: router.experimental_gesturePush,
bfcacheId: '_b_' + bfcacheIdNumber + '_',
}),
[router, bfcacheIdNumber]
)
}
/**
@@ -245,10 +245,14 @@ function updateCacheNodeOnNavigation(
parentRefreshState: RefreshState | null,
accumulation: NavigationRequestAccumulation
): NavigationTask | null {
// Check if this segment matches the one in the previous route.
// Check if this segment matches the one in the previous route. A
// search-param-only difference at a page segment falls through to the
// matched branch — the CacheNode is rebuilt (so data refetches), but the
// bfcacheId carries forward as if the segment had matched.
const oldSegment = oldRouterState[0]
const newSegment = createSegmentFromRouteTree(newRouteTree)
if (!matchSegment(newSegment, oldSegment)) {
const segmentMatchKind = compareSegments(newSegment, oldSegment)
if (segmentMatchKind === SegmentMatchKind.Change) {
// This segment does not match the previous route. We're now entering the
// new part of the target route. Switch to the "create" path.
if (
@@ -347,7 +351,11 @@ function updateCacheNodeOnNavigation(
oldCacheNode !== undefined &&
!shouldRefreshDynamicData &&
// During a same-page navigation, we always refetch the page segments
!(isLeafSegment && isSamePageNavigation)
!(isLeafSegment && isSamePageNavigation) &&
// A search-param-only change is treated as a refresh of the page segment.
// The internal cache key of the data is different, but the identity of
// the node in the route tree is the same.
segmentMatchKind !== SegmentMatchKind.SearchParamOnlyChange
) {
// Reuse the existing CacheNode
const dropPrefetchRsc = false
@@ -364,18 +372,34 @@ function updateCacheNodeOnNavigation(
newMetadataVaryPath,
seedHead,
freshness,
seedDynamicStaleAt
seedDynamicStaleAt,
// Carry forward the existing bfcacheId when there's a prior CacheNode:
// even though the data is being refreshed, the state identity of the
// route hasn't changed. Otherwise (no prior node) mint a fresh one.
oldCacheNode !== undefined
? oldCacheNode.bfcacheId
: generateBFCacheId(freshness)
)
newCacheNode = result.cacheNode
needsDynamicRequest = result.needsDynamicRequest
// Carry forward the old node's scrollRef. This preserves scroll
// intent when a prior navigation's cache node is replaced by a
// refresh before the scroll handler has had a chance to fire —
// e.g. when router.push() and router.refresh() are called in the
// same startTransition batch.
if (oldCacheNode !== undefined) {
newCacheNode.scrollRef = oldCacheNode.scrollRef
// Scroll handling
if (
isLeafSegment &&
segmentMatchKind === SegmentMatchKind.SearchParamOnlyChange
) {
// Special case: A search param change mostly acts the same as a
// refresh, except it does trigger a scroll.
accumulateScrollRef(freshness, newCacheNode, accumulation)
} else {
// Normal case: This is a refresh of an existing segment. Carry forward
// the old node's scrollRef. This preserves scroll intent when a prior
// navigation's CacheNode is replaced by a refresh before the scroll
// handler has had a chance to fire — e.g. when router.push() and
// router.refresh() are called in the same startTransition batch.
if (oldCacheNode !== undefined) {
newCacheNode.scrollRef = oldCacheNode.scrollRef
}
}
}
@@ -636,7 +660,10 @@ function createCacheNodeOnNavigation(
newMetadataVaryPath,
seedHead,
freshness,
seedDynamicStaleAt
seedDynamicStaleAt,
// This segment was not part of the previous route, so mint a fresh
// bfcacheId.
generateBFCacheId(freshness)
)
const newCacheNode = result.cacheNode
const needsDynamicRequest = result.needsDynamicRequest
@@ -879,11 +906,14 @@ function reuseSharedCacheNode(
// Clone the CacheNode that was already present in the previous tree.
// Carry forward the scrollRef so scroll intent from a prior navigation
// survives tree rebuilds (e.g. push + refresh in the same batch).
// Carry forward the bfcacheId so shared-layout segments retain stable
// identity across navigations.
return createCacheNode(
existingCacheNode.rsc,
dropPrefetchRsc ? null : existingCacheNode.prefetchRsc,
existingCacheNode.head,
dropPrefetchRsc ? null : existingCacheNode.prefetchHead,
existingCacheNode.bfcacheId,
existingCacheNode.scrollRef
)
}
@@ -895,7 +925,8 @@ function createCacheNodeForSegment(
metadataVaryPath: PageVaryPath | null,
seedHead: HeadData | null,
freshness: FreshnessPolicy,
dynamicStaleAt: number
dynamicStaleAt: number,
bfcacheId: number
): { cacheNode: CacheNode; needsDynamicRequest: boolean } {
// Construct a new CacheNode using data from the BFCache, the client's
// Segment Cache, or seeded from a server response.
@@ -927,12 +958,17 @@ function createCacheNodeForSegment(
tree.varyPath
)
if (bfcacheEntry !== null) {
// A regular navigation that happens to read cached data is still a
// fresh navigation, so we use the caller-supplied bfcacheId — the
// BFCacheEntry's id is only restored on history-traversal
// navigations.
return {
cacheNode: createCacheNode(
bfcacheEntry.rsc,
bfcacheEntry.prefetchRsc,
bfcacheEntry.head,
bfcacheEntry.prefetchHead
bfcacheEntry.prefetchHead,
bfcacheId
),
needsDynamicRequest: false,
}
@@ -967,7 +1003,8 @@ function createCacheNodeForSegment(
prefetchRsc,
head,
prefetchHead,
dynamicStaleAt
dynamicStaleAt,
bfcacheId
)
if (isPage && metadataVaryPath !== null) {
writeHeadToBFCache(
@@ -975,11 +1012,18 @@ function createCacheNodeForSegment(
metadataVaryPath,
head,
prefetchHead,
dynamicStaleAt
dynamicStaleAt,
bfcacheId
)
}
return {
cacheNode: createCacheNode(rsc, prefetchRsc, head, prefetchHead),
cacheNode: createCacheNode(
rsc,
prefetchRsc,
head,
prefetchHead,
bfcacheId
),
needsDynamicRequest: false,
}
}
@@ -1000,12 +1044,16 @@ function createCacheNodeForSegment(
const oldRscDidResolve =
!isDeferredRsc(oldRsc) || oldRsc.status !== 'pending'
const dropPrefetchRsc = oldRscDidResolve
// Restore the bfcacheId from the cached entry so that back/forward
// navigations preserve the original id, regardless of whether
// `cacheComponents` Activity preservation is enabled.
return {
cacheNode: createCacheNode(
bfcacheEntry.rsc,
dropPrefetchRsc ? null : bfcacheEntry.prefetchRsc,
bfcacheEntry.head,
dropPrefetchRsc ? null : bfcacheEntry.prefetchHead
dropPrefetchRsc ? null : bfcacheEntry.prefetchHead,
bfcacheEntry.bfcacheId
),
needsDynamicRequest: false,
}
@@ -1208,7 +1256,8 @@ function createCacheNodeForSegment(
prefetchRsc,
head,
prefetchHead,
dynamicStaleAt
dynamicStaleAt,
bfcacheId
)
if (isPage && metadataVaryPath !== null) {
writeHeadToBFCache(
@@ -1216,13 +1265,14 @@ function createCacheNodeForSegment(
metadataVaryPath,
head,
prefetchHead,
dynamicStaleAt
dynamicStaleAt,
bfcacheId
)
}
}
return {
cacheNode: createCacheNode(rsc, prefetchRsc, head, prefetchHead),
cacheNode: createCacheNode(rsc, prefetchRsc, head, prefetchHead, bfcacheId),
// TODO: We should store this field on the CacheNode itself. I think we can
// probably unify NavigationTask, CacheNode, and DeferredRsc into a
// single type. Or at least CacheNode and DeferredRsc.
@@ -1236,6 +1286,7 @@ function createCacheNode(
prefetchRsc: React.ReactNode | null,
head: React.ReactNode | null,
prefetchHead: HeadData | null,
bfcacheId: number,
scrollRef: ScrollRef | null = null
): CacheNode {
return {
@@ -1245,9 +1296,56 @@ function createCacheNode(
prefetchHead,
slots: null,
scrollRef,
bfcacheId,
}
}
// Globally-unique counter for fresh bfcacheIds. Incremented every time a new
// CacheNode is created on the client. The id surfaces to user code as a
// string via `useRouter().bfcacheId`.
let nextBFCacheId = 0
function generateBFCacheId(freshness: FreshnessPolicy): number {
// Server-side rendering and the initial client-side hydration tree both
// use a fixed sentinel so they reconcile cleanly across hydration. The
// counter only advances on real client-side navigations after hydration.
if (typeof window === 'undefined') return 0
if (freshness === FreshnessPolicy.Hydration) return 0
return ++nextBFCacheId
}
const enum SegmentMatchKind {
// Two segments are equivalent: the CacheNode can be reused as-is.
Match,
// The segments differ in the parts that determine the route (segment kind,
// dynamic param value, etc.). The CacheNode must be created fresh.
Change,
// Two page segments differ only in their search params. Conceptually this
// is a refresh of the current page rather than a navigation to a new
// route — search params don't contribute to the LayoutRouter state key,
// and they shouldn't change the bfcacheId either. The CacheNode is rebuilt
// (so data refetches) but the bfcacheId carries forward.
SearchParamOnlyChange,
}
function compareSegments(
newSegment: Segment,
oldSegment: Segment
): SegmentMatchKind {
if (matchSegment(newSegment, oldSegment)) {
return SegmentMatchKind.Match
}
if (
typeof newSegment === 'string' &&
typeof oldSegment === 'string' &&
newSegment.startsWith(PAGE_SEGMENT_KEY) &&
oldSegment.startsWith(PAGE_SEGMENT_KEY)
) {
return SegmentMatchKind.SearchParamOnlyChange
}
return SegmentMatchKind.Change
}
// Represents whether the previuos navigation resulted in a route tree mismatch.
// A mismatch results in a refresh of the page. If there are two successive
// mismatches, we will fall back to an MPA navigation, to prevent a retry loop.
@@ -34,6 +34,11 @@ export type BFCacheEntry = {
head: React.ReactNode | null
prefetchHead: React.ReactNode | null
// The bfcacheId of the CacheNode that wrote this entry. Restored on
// history-traversal navigations so that `useRouter().bfcacheId` is stable
// across back/forward, even without `cacheComponents` Activity preservation.
bfcacheId: number
ref: UnknownMapEntry | null
size: number
// The time at which this data was received. Used to compute the stale time
@@ -64,7 +69,8 @@ export function writeToBFCache(
prefetchRsc: React.ReactNode,
head: React.ReactNode,
prefetchHead: React.ReactNode,
dynamicStaleAt: number
dynamicStaleAt: number,
bfcacheId: number
): void {
if (typeof window === 'undefined') {
return
@@ -79,6 +85,8 @@ export function writeToBFCache(
head,
prefetchHead,
bfcacheId,
ref: null,
// TODO: This is just a heuristic. Getting the actual size of the segment
// isn't feasible because it's part of a larger streaming response. The
@@ -104,10 +112,20 @@ export function writeHeadToBFCache(
varyPath: SegmentVaryPath,
head: React.ReactNode,
prefetchHead: React.ReactNode,
dynamicStaleAt: number
dynamicStaleAt: number,
bfcacheId: number
): void {
// Read the special "segment" that represents the head data.
writeToBFCache(now, varyPath, head, prefetchHead, null, null, dynamicStaleAt)
writeToBFCache(
now,
varyPath,
head,
prefetchHead,
null,
null,
dynamicStaleAt,
bfcacheId
)
}
/**
@@ -68,6 +68,25 @@ export interface AppRouterInstance {
* @experimental
*/
experimental_gesturePush?(href: string, options?: NavigateOptions): void
/**
* An opaque string identifier scoped to the current route segment.
*
* Changes when the surrounding segment is freshly created by a push or
* replace navigation. Stays the same for back/forward navigations,
* `router.refresh()`, and search-param/hash-only changes.
*
* Intended to be passed to a React `key` to opt out of state preservation
* on fresh navigations:
*
* ```tsx
* <form key={useRouter().bfcacheId}>
* ```
*
* In most cases, prefer resetting state explicitly in an event handler, or
* deriving a key from your data (e.g. a draft id from the server). Use
* `bfcacheId` only when those patterns aren't a fit.
*/
bfcacheId: string
}
export const AppRouterContext = React.createContext<AppRouterInstance | null>(
@@ -60,6 +60,18 @@ export type CacheNode = {
* layout segment).
*/
scrollRef: ScrollRef | null
/**
* Globally-unique identifier minted from a monotonic counter when the
* CacheNode is freshly created. Surfaced to user code as a string via
* `useRouter().bfcacheId` and intended to be used as a React `key` to
* opt out of Activity-based state preservation on fresh navigations.
*
* Preserved when the CacheNode is reused (shared layouts, refresh,
* search/hash-only navigations) or restored from the BFCache during a
* back/forward navigation.
*/
bfcacheId: number
}
/**
@@ -32,6 +32,9 @@ export function adaptForAppRouterInstance(
prefetch(href) {
void pagesRouter.prefetch(href)
},
// The bfcacheId concept is App Router-only. Surfaced as a stable
// placeholder so consumers using this adapter don't crash.
bfcacheId: '0',
}
}
@@ -0,0 +1,51 @@
'use client'
import { useState } from 'react'
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
import { LinkAccordion } from '../../../components/link-accordion'
import { refreshAction } from '../../actions'
export function DynamicRenderCounterClient({ uuid }: { uuid: string }) {
// Counts how many times this component has received a new uuid from the
// server. The count lives in React state, so it's preserved when the
// bfcacheId is stable across navigations and reset when the segment is
// recreated. Useful as a visual signal that the dynamic part re-ran.
const [count, setCount] = useState(0)
const [prevUuid, setPrevUuid] = useState(uuid)
if (prevUuid !== uuid) {
setPrevUuid(uuid)
setCount(count + 1)
}
return <p data-testid="dynamic-render-counter">dynamic renders: {count}</p>
}
export function LeafContent() {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const { bfcacheId } = router
const search = searchParams.toString()
return (
<>
<h1 data-testid="pathname">{pathname}</h1>
<span data-testid="search" data-value={search}>
{search}
</span>
<form key={bfcacheId}>
<input data-testid="leaf-input" defaultValue="" />
</form>
<LinkAccordion href={`${pathname}?q=2`}>same page (?q=2)</LinkAccordion>
<LinkAccordion href={`${pathname}#section`}>
same page (#section)
</LinkAccordion>
<button data-testid="refresh" onClick={() => router.refresh()}>
refresh
</button>
<form action={refreshAction}>
<button data-testid="server-action-refresh" type="submit">
server action refresh
</button>
</form>
</>
)
}
@@ -0,0 +1,22 @@
import { Suspense } from 'react'
import { connection } from 'next/server'
import { DynamicRenderCounterClient, LeafContent } from './leaf-content'
async function DynamicRenderCounter() {
// Renders a count of the number of times the client receives new dynamic data
// from the server. The count is computed on the client and stored in React
// state, so it gets reset if the state of the tree is reset.
await connection()
return <DynamicRenderCounterClient uuid={crypto.randomUUID()} />
}
export default function LeafPage() {
return (
<section>
<Suspense fallback={null}>
<DynamicRenderCounter />
</Suspense>
<LeafContent />
</section>
)
}
@@ -0,0 +1,22 @@
'use client'
import { useRouter } from 'next/navigation'
import { ReactNode, Suspense } from 'react'
import { LinkAccordion } from '../../components/link-accordion'
export default function GroupLayout({ children }: { children: ReactNode }) {
const { bfcacheId } = useRouter()
return (
<div>
<nav>
<LinkAccordion href="/x/1">/x/1</LinkAccordion>
<LinkAccordion href="/x/2">/x/2</LinkAccordion>
<LinkAccordion href="/y/1">/y/1</LinkAccordion>
</nav>
<form key={bfcacheId}>
<input data-testid="layout-input" defaultValue="" />
</form>
<Suspense fallback={null}>{children}</Suspense>
</div>
)
}
@@ -0,0 +1,7 @@
'use server'
import { refresh } from 'next/cache'
export async function refreshAction() {
refresh()
}
@@ -0,0 +1,9 @@
import { ReactNode } from 'react'
export default function Root({ children }: { children: ReactNode }) {
return (
<html>
<body>{children}</body>
</html>
)
}
@@ -0,0 +1,17 @@
import { LinkAccordion } from '../components/link-accordion'
export default function Page() {
return (
<ul>
<li>
<LinkAccordion href="/x/1">/x/1</LinkAccordion>
</li>
<li>
<LinkAccordion href="/x/2">/x/2</LinkAccordion>
</li>
<li>
<LinkAccordion href="/y/1">/y/1</LinkAccordion>
</li>
</ul>
)
}
@@ -0,0 +1,33 @@
'use client'
import Link, { LinkProps } from 'next/link'
import { useState } from 'react'
export function LinkAccordion({
href,
children,
prefetch,
}: {
href: string
children: React.ReactNode
prefetch?: LinkProps['prefetch']
}) {
const [isVisible, setIsVisible] = useState(false)
return (
<>
<input
type="checkbox"
checked={isVisible}
onChange={() => setIsVisible(!isVisible)}
data-link-accordion={href}
/>
{isVisible ? (
<Link href={href} prefetch={prefetch}>
{children}
</Link>
) : (
`${children} (link is hidden)`
)}
</>
)
}
@@ -0,0 +1,8 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
cacheComponents: true,
}
module.exports = nextConfig
@@ -0,0 +1,172 @@
import { nextTestSetup } from 'e2e-utils'
import type * as Playwright from 'playwright'
import { createRouterAct } from 'router-act'
describe('use-router-bfcache-id', () => {
const { next } = nextTestSetup({
files: __dirname,
})
async function setup(initialPath: string) {
let page: Playwright.Page
const browser = await next.browser(initialPath, {
beforePageLoad(p: Playwright.Page) {
page = p
},
})
const act = createRouterAct(page!)
return { browser, act }
}
it('preserves leaf form state on browser back navigation', async () => {
const { browser, act } = await setup('/x/1')
await browser.elementByCss('[data-testid="leaf-input"]').type('hello')
await act(async () => {
await browser.elementByCss('input[data-link-accordion="/x/2"]').click()
await browser.elementByCss('a[href="/x/2"]').click()
})
await browser.back()
expect(await browser.elementByCss('[data-testid="pathname"]').text()).toBe(
'/x/1'
)
expect(
await browser.elementByCss('[data-testid="leaf-input"]').getValue()
).toBe('hello')
})
it('resets leaf form state when re-entering a route via fresh push', async () => {
const { browser, act } = await setup('/x/1')
await browser.elementByCss('[data-testid="leaf-input"]').type('hello')
await act(async () => {
await browser.elementByCss('input[data-link-accordion="/x/2"]').click()
await browser.elementByCss('a[href="/x/2"]').click()
})
// Navigate back to /x/1 via a fresh push (not the browser back button).
await act(async () => {
await browser.elementByCss('input[data-link-accordion="/x/1"]').click()
await browser.elementByCss('a[href="/x/1"]').click()
})
expect(
await browser.elementByCss('[data-testid="leaf-input"]').getValue()
).toBe('')
})
it('preserves shared layout state across sibling leaf navigations', async () => {
const { browser, act } = await setup('/x/1')
await browser.elementByCss('[data-testid="layout-input"]').type('layout')
await act(async () => {
await browser.elementByCss('input[data-link-accordion="/x/2"]').click()
await browser.elementByCss('a[href="/x/2"]').click()
})
// The [group] layout is shared across /x/1 and /x/2, so its form state
// survives the leaf navigation.
expect(
await browser.elementByCss('[data-testid="layout-input"]').getValue()
).toBe('layout')
})
it('resets shared layout state when navigating across groups', async () => {
const { browser, act } = await setup('/x/1')
await browser.elementByCss('[data-testid="layout-input"]').type('layout')
await act(async () => {
await browser.elementByCss('input[data-link-accordion="/y/1"]').click()
await browser.elementByCss('a[href="/y/1"]').click()
})
// /y/1 and /x/1 don't share the [group] layout — the form is mounted
// fresh and its state is reset.
expect(
await browser.elementByCss('[data-testid="layout-input"]').getValue()
).toBe('')
})
it('preserves form state across search-param navigation', async () => {
const { browser, act } = await setup('/x/1')
await browser.elementByCss('[data-testid="leaf-input"]').type('hello')
await act(async () => {
await browser
.elementByCss('input[data-link-accordion="/x/1?q=2"]')
.click()
await browser.elementByCss('a[href="/x/1?q=2"]').click()
})
expect(
await browser.elementByCss('[data-testid="search"][data-value="q=2"]')
).toBeDefined()
expect(
await browser.elementByCss('[data-testid="leaf-input"]').getValue()
).toBe('hello')
})
it('preserves form state across router.refresh()', async () => {
const { browser, act } = await setup('/x/1')
await browser.elementByCss('[data-testid="leaf-input"]').type('hello')
await act(async () => {
await browser.elementByCss('[data-testid="refresh"]').click()
})
expect(
await browser.elementByCss('[data-testid="leaf-input"]').getValue()
).toBe('hello')
})
it('preserves form state when returning to a search-param URL via browser back', async () => {
const { browser, act } = await setup('/x/1')
await browser.elementByCss('[data-testid="leaf-input"]').type('hello')
await act(async () => {
await browser
.elementByCss('input[data-link-accordion="/x/1?q=2"]')
.click()
await browser.elementByCss('a[href="/x/1?q=2"]').click()
})
expect(
await browser.elementByCss('[data-testid="search"][data-value="q=2"]')
).toBeDefined()
expect(
await browser.elementByCss('[data-testid="leaf-input"]').getValue()
).toBe('hello')
await act(async () => {
await browser.elementByCss('input[data-link-accordion="/x/2"]').click()
await browser.elementByCss('a[href="/x/2"]').click()
})
await browser.back()
expect(await browser.elementByCss('[data-testid="pathname"]').text()).toBe(
'/x/1'
)
expect(
await browser.elementByCss('[data-testid="search"][data-value="q=2"]')
).toBeDefined()
expect(
await browser.elementByCss('[data-testid="leaf-input"]').getValue()
).toBe('hello')
})
it('preserves form state across a server action that calls refresh()', async () => {
const { browser, act } = await setup('/x/1')
await browser.elementByCss('[data-testid="leaf-input"]').type('hello')
await act(async () => {
await browser
.elementByCss('[data-testid="server-action-refresh"]')
.click()
})
expect(
await browser.elementByCss('[data-testid="leaf-input"]').getValue()
).toBe('hello')
})
})