[cacheComponents] Activity component route preservation causes significant breakage in application logic, UI behavior and E2E tests
Link to the code that reproduces this issue
https://github.com/SorooshGb/next16-activity-breakage-repro
To Reproduce
This reproduction demonstrates 3 issues caused by Activity-component–driven route preservation when cacheComponents: true is enabled
-
Clone the reproduction repo linked above.
-
Install dependencies: npm install npx playwright install chromium
-
Start the dev server: npm run dev
-
Test the following behaviors in the browser:
A. Dropdown stays open
- On the home page, open the dropdown once. In this reproduction, the dropdown’s default close-on-select behavior is intentionally prevented because of application logic requirements.
- Click any dropdown button that navigates to an Item page.
- Navigate back to the home page.
→ The dropdown is still open because the component never unmounts.
B. Dialog behavior breaks because mount-time logic never runs again
Each row in the dropdown contains two navigation buttons:
- “View Item” (eye icon) → navigates to
/item/[id]without search params - “Add Item” (plus icon) → navigates to
/item/[id]?newEntry=true
Expected behavior:
- “Add Item” → dialog should open on arrival
- “View Item” → dialog should remain closed
Actual behavior:
- Reload the home page to start clean.
- Open the dropdown and click “Add Item” (
?newEntry=true).
→ Dialog opens as expected. - Use the browser Back button to return home.
- Click “View Item” (no
newEntryparam).
→ The dialog is still open even though the URL does not include the param.
Now demonstrate that once closed, the dialog never opens again:
- Reload the home page again.
- Open the dropdown and click “Add Item” (
?newEntry=true), then close the dialog manually. - Navigate back to the home page.
- Open the dropdown and click “Add Item” again (
?newEntry=true).
→ The dialog does not open, even though the URL includes the param.
This shows that mount-time initialization (including logic based on search params) only runs once, and the dialog’s open state becomes permanently tied to stale state.
C. E2E test fails due to duplicate DOM
With the dev server still running, execute:
npm run test:e2e
This test visits /sign-in first (which contains Email and Password fields), then clicks the
link to navigate to /sign-up (which also contains Email and Password fields).
Because Activity keeps the previous route mounted, the hidden /sign-in page remains in the
DOM when /sign-up loads, resulting in two Email fields and two Password fields existing at
the same time.
→ Playwright strict mode fails because getByLabel("Email") and getByLabel("Password")
match multiple elements, triggering strict-mode violations.
Current vs. Expected behavior
Expected Behavior:
With cacheComponents disabled, the app behaves correctly:
- Components reset normally (dropdown closes, dialog opens/closes based on initial state passed to it)
- E2E selectors match only the elements on the active page
This is the behavior the app always had before Next.js 16 — including when using the older
use cache / PPR canary features — none of which introduced any Activity component behavior.
Current Behavior:
With cacheComponents: true, Next.js preserves previous routes using Activity:
- Components do not unmount when navigating
- Hidden DOM from previous pages remains present
- UI state persists across unrelated pages (dropdown stays open)
- Dialog components do not re-run mount-time logic
- E2E tests fail because multiple matching fields are in the DOM
Provide environment information
Binaries:
Node: 22.16.0
npm: 11.4.2
Yarn: 1.22.22
pnpm: 10.13.1
Relevant Packages:
next: 16.0.5 // Latest available version is detected (16.0.5).
react: 19.2.0
react-dom: 19.2.0
Next.js Config:
output: N/A
Which area(s) are affected? (Select all that apply)
cacheComponents
Which stage(s) are affected? (Select all that apply)
next dev (local), next start (local), Vercel (Deployed), Other (Deployed)
Additional context
All issues mentioned above disappear immediately if cacheComponents is set to false,
but doing so also disables the new "use cache" and PPR features.
After upgrading to Next.js 16 and enabling cacheComponents: true, unexpected behavior began
to appear in my application. The first issue was the portal visibility bug (which I also
reported here: https://github.com/vercel/next.js/issues/85390). React fixed that bug upstream, and it was resolved in Next.js 16.0.2.
However, after upgrading, additional issues still surfaced.
Because Activity changes fundamental assumptions about route lifecycle, there is a high chance that more unexpected behaviors will appear in applications that rely on normal unmount-on-navigation semantics.
Each issue now requires manual workaround logic. For example, controlling the dialog open
state requires syncing it inside a useEffect because the component no longer remounts.
That workaround triggers a React ESLint warning:
Error: Calling setState synchronously within an effect can trigger cascading renders
Avoid calling setState() directly within an effect
for the E2E test to pass, the Playwright selectors must be changed to:
page.getByLabel("Email").filter({ visible: true })
This avoids strict-mode violations caused by duplicate hidden fields, even though Playwright generally advises against relying on .filter({ visible: true }) as a primary testing strategy.
Will this be solved by manually clearing the params on router change?
If you clear the search params, you still need a router.refresh() or router.replace(), which forces an unnecessary server + client re-render. And doesn't even solve anything, the real issue is when routes change, Next.js keeps previous pages mounted via <Activity> component, so their state is preserved instead of being re-initialized when you return to them. So I can't initialize the open state of the dialog based on search params when that route is visited.
In my app, it happens that I do clear the search params after closing the dialog. And I could do a little workaround, by deriving the state and passing both the derived state and the open state to the dialog like this:
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const [open, setOpen] = useState(false);
function handleClose() {
setOpen(false);
if (searchParams.has("newItem")) {
const sp = new URLSearchParams(searchParams);
sp.delete("newTrade");
const qs = sp.toString();
const nextUrl = qs ? `${pathname}?${qs}` : pathname;
router.replace(nextUrl, { scroll: false });
}
}
const defaultOpen = searchParams.get("newItem") === "true";
And in the dialog:
// Because Next.js keeps this page mounted via <Activity>,
// we can’t rely on initial state. Re-open based on the query param.
open={defaultOpen || open}
setOpen={(isOpen) => {
if (!isOpen) handleClose();
else setOpen(true);
}}
This doesn’t solve the underlying state-persistence issue, but it keeps the dialog in sync with ?newItem=true on initial page load, and then hands control back to the dialog’s own open state after it’s closed. This matters because the dialog trigger lives on the same page and must still be able to open the dialog independently.
Just yesterday I hit another unintended and unexpected issue.
Here’s a simplified example:
Imagine you have a /new-product page that displays a form for creating a new product. You submit the form, get redirected to the product page, work with the product do whatever needs to be done there. Then you’re ready to add another one, so you click the “New Product” link again.
You land on /new-product and every single field is still filled with the values from the previous product you created.
Now you also have to add a useEffect cleanup to manually reset your form fields, which is completely unnecessary in a normal routing model.
The number of regressions and workarounds this introduces is ridiculous. And the worst part is that Next.js could easily change or remove the <Activity> behavior in a future update — at which point we’d have to remember every place we implemented these hacks and extra complexity and painstakingly revert them.
Yh. I have had similar issues like the form. I solved it using RHF which provides a reset function.
I think they would have to separate the activity component feature from cache components. There is a reason this is still opt for now because they would need to iron out some issues like this.
Also the idea is great honestly. Sometimes when a user fills a form and navigate to another route mistakenly, with this feature they don't have to fill the form again unless they submit which would manually reset the form.
The best case scenario is to make the activity component feature separate and opt in too. So in your case, u can simply disable it.
I also think the best case scenario for now is to turn off cache components for now instead of implementing a work around. Like you said, they might change the behavior in next major update.
Hacky workaround if you want the old behaviour: useEffect cleanup functions still fire when you navigate away from the current page, so use that to increment some value which is used as a key to reset state.
Update: see comment below from icyJoseph this is not recommended!
"use client";
import { ReactNode, Fragment, useEffect } from "react";
const componentKey = { val: 0 };
export function ResetStateOnUnmount(props: { children: ReactNode }) {
useEffect(() => {
return () => {
componentKey.val++;
};
}, []);
return <Fragment key={componentKey.val}>{props.children}</Fragment>;
}
Now wrap your page content if you want its state to be reset before you next navigate to it:
// page.tsx
export default function MyPage() {
return (
<ResetStateOnUnmount>
<div>
<Dialog />
<Form />
</div>
</ResetStateOnUnmount>
);
}
Though I suspect it's probably best to either not enable cacheComponents, or to embrace the new model and deal with any problems at source. e.g. on [itemId] page treat the url query params as the source of truth for the open/close state (and update them when manually opening), and on the home page close the dialog in the onClick/onNavigate callback of the Link
We are taking in the feedback. That component right there is not recommended, keep its usage to a minimum, it de-opts any nested Activities too.
The thing is that, eventually, user and library code are likely to use Activity and your components wrapped by them would also need to handle this behavior. What we are seeing here is that more education will be needed on React's side, and hopefully library maintainers will implement some clean up and controls accounting for Activity's presence somewhere in the tree.
When an Activity changes to 'hidden', React keeps it in the DOM and preserves state, but it also counts as a React unmount - so you can clean up on useEffects and ref.
Once again, we are taking in the feedback, thanks for taking the time to write up and follow up.
I think it's a bit frustrating that the previous caching features, albeit experimental, are now bundled up with Activity. That would be fine if Activity wasn't a breaking change, but it is. Even patterns which I assume are common are broken by this -- for example, using useActionState's state (which can't be reset) with useEffect. There are other approaches but, for large projects, it'll take a large amount of refactoring just to use use cache.
Same issue here and i discovered this behavior after a major refactor to use cacheComponent. Agreed with @Chibuike-web this feature should be separated from cacheComponent.