next.js icon indicating copy to clipboard operation
next.js copied to clipboard

[cacheComponents] Activity component route preservation causes significant breakage in application logic, UI behavior and E2E tests

Open SorooshGb opened this issue 1 month ago • 8 comments

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

  1. Clone the reproduction repo linked above.

  2. Install dependencies: npm install npx playwright install chromium

  3. Start the dev server: npm run dev

  4. 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:

  1. Reload the home page to start clean.
  2. Open the dropdown and click “Add Item” (?newEntry=true).
    → Dialog opens as expected.
  3. Use the browser Back button to return home.
  4. Click “View Item” (no newEntry param).
    → The dialog is still open even though the URL does not include the param.

Now demonstrate that once closed, the dialog never opens again:

  1. Reload the home page again.
  2. Open the dropdown and click “Add Item” (?newEntry=true), then close the dialog manually.
  3. Navigate back to the home page.
  4. 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.

SorooshGb avatar Nov 27 '25 11:11 SorooshGb

Will this be solved by manually clearing the params on router change?

Chibuike-web avatar Nov 29 '25 07:11 Chibuike-web

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.

SorooshGb avatar Nov 29 '25 08:11 SorooshGb

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.

SorooshGb avatar Nov 29 '25 08:11 SorooshGb

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.

Chibuike-web avatar Nov 29 '25 08:11 Chibuike-web

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

mdj-uk avatar Nov 29 '25 13:11 mdj-uk

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.

icyJoseph avatar Dec 01 '25 21:12 icyJoseph

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.

gghdev avatar Dec 04 '25 08:12 gghdev

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.

Alexandredc avatar Dec 08 '25 08:12 Alexandredc