Skip to content
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

Calling revalidatePath() or revalidateTag() in Server Actions that are triggered from Intercepting Routes breaks useFormStatus() and useFormState() #58772

Closed
1 task done
flsilva opened this issue Nov 22, 2023 · 5 comments · Fixed by #59585
Labels
bug Issue was opened via the bug report template. linear: next Confirmed issue that is tracked by the Next.js team. locked

Comments

@flsilva
Copy link

flsilva commented Nov 22, 2023

Link to the code that reproduces this issue

https://github.com/flsilva/next-v14-revalidate-issue-1

To Reproduce

  1. Visit https://next-v14-revalidate-issue-1.vercel.app
  2. Scroll down and fill in the name input text.
  3. Click the Submit button in the /src/app/page.tsx box.
  4. It works as expected. The Submit button changes to Submitting... and after 3 seconds it changes back to Submit, and you can see the Server Action's response.
  5. Click Nav to /foo to visit the Intercepting Route /foo.
  6. Fill in the name input text in either form.
  7. Click the Submit button in either form.
  8. It doesn't work as expected. The Submit button changes to Submitting... but never changes back to Submit, and we never get the Server Action's response. Client Components never re-render.

Current vs. Expected behavior

When we're at the /foo Intercepting Route we should see the same behavior we see when we're at the root route /, i.e., after clicking the Submit button it should change to Submitting... and after 3 seconds it should change back to Submit, and we should see the Server Action's response.

Verify canary release

  • I verified that the issue exists in the latest Next.js canary release

Provide environment information

Operating System:
  Platform: darwin
  Arch: arm64
  Version: Darwin Kernel Version 22.5.0: Thu Jun  8 22:22:23 PDT 2023; root:xnu-8796.121.3~7/RELEASE_ARM64_T6020
Binaries:
  Node: 18.17.1
  npm: 9.6.7
  Yarn: N/A
  pnpm: N/A
Relevant Packages:
  next: 14.0.4-canary.9
  eslint-config-next: N/A
  react: 18.2.0
  react-dom: 18.2.0
  typescript: 5.1.3
Next.js Config:
  output: N/A

Which area(s) are affected? (Select all that apply)

App Router

Additional context

I can reproduce the issue in v14.0.4-canary.9 on localhost and Vercel.

Code changes that results in the same behavior:

  1. Changing revalidatePath('/') to revalidatePath('/', 'page').
  2. Changing revalidatePath('/') to revalidatePath('/', 'layout').
  3. Changing revalidatePath('/') to revalidatePath('/foo').
  4. Changing revalidatePath('/') to revalidatePath('/foo', 'page').
  5. Changing revalidatePath('/') to revalidatePath('/foo', 'layout').
  6. Replacing revalidatePath() with revalidateTag('anything').

Code changes that results in the expected behavior:

  1. If we comment out the revalidatePath('/') line of code in action.ts everything works as expected because we're not relying on cached data in this app.

Routing structure:

app
├── @intercept
│   ├── (.)foo
│   │   └── page.tsx
│   ├── [...catchAll]
│   │   └── default.tsx
│   └── default.tsx
│   └── page.tsx
├── foo
│   └── page.tsx
├── layout.tsx
└── page.tsx

Relevant code:

app/action.ts

"use server";

import { revalidatePath } from "next/cache";

export const action = async (
  previousState: string | undefined,
  formData: FormData
) => {
  const name = formData.get("name");
  await new Promise((resolve) => setTimeout(resolve, 3000));
  revalidatePath("/");
  return `name: ${name} | Math.random(): ${(Math.random() * 100).toFixed(2)}`;
};

app/MyForm.tsx

"use client";

import { useFormState } from "react-dom";
import { SubmitButton } from "./SubmitButton";
import { action } from "./action";

export const MyForm = () => {
  const [result, formAction] = useFormState(action, undefined);

  return (
    <form action={formAction}>
      <input type="text" name="name" placeholder="Name" autoComplete="off" />
      <SubmitButton />
      <p>Response: {result}</p>
    </form>
  );
};

app/SubmitButton.tsx

"use client";

import { useFormStatus } from "react-dom";

export const SubmitButton = () => {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Submitting..." : "Submit"}
    </button>
  );
};

Replay:

next-v14-revalidate-issue-1

NEXT-1815

@flsilva flsilva added the bug Issue was opened via the bug report template. label Nov 22, 2023
@flsilva flsilva changed the title Calling revalidatePath() or revalidateTag() in Server Actions that are triggered from Intercepting Routes breaks useFormStatus and useFormState Calling revalidatePath() or revalidateTag() in Server Actions that are triggered from Intercepting Routes breaks useFormStatus() and useFormState() Nov 22, 2023
@ahrbil
Copy link

ahrbil commented Nov 25, 2023

@flsilva I don't know if this is related to your issue, but have you noticed the action prop value on the form tag?
action="javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')"
🤔

@gipeey
Copy link

gipeey commented Nov 25, 2023

i'm here with the same issue too
image

@zitooo
Copy link

zitooo commented Nov 27, 2023

Same here. Using revalidateTag in a server action invoked in an intercepted route maintains the pending status forever, both on the intercepting and intercepted pages (in my case, @modal/(.)post/[slug]/page.js and /post/[ slug]/page.js). Deleting the @modal folder, the other one works correctly. Removing revalidateTag from the server action, both work. Using version 14.0.3

@balazsorban44 balazsorban44 added the linear: next Confirmed issue that is tracked by the Next.js team. label Dec 6, 2023
@balazsorban44
Copy link
Member

Thanks, we will look into this! Anyone having a similar issue, please note that "same issue" without a reproducible code example is not useful. Please post your minimal reproduction, that we can have a look to verify, once a a fix has been made 🙏

ztanner added a commit that referenced this issue Dec 15, 2023
…59585)

### What?
There are a bunch of different bugs caused by the same underlying issue,
but the common thread is that performing any sort of router cache update
(either through `router.refresh()`, `revalidatePath()`, or `redirect()`)
inside of a parallel route would break the router preventing subsequent
actions, and not resolve any pending state such as from `useFormState`.

### Why?
`applyPatch` is responsible for taking an update response from the
server and merging it into the client router cache. However, there's
specific bailout logic to skip over applying the patch to a
`__DEFAULT__` segment (which corresponds with a `default.tsx` page).
When the router detects a cache node that is expected to be rendered on
the page but contains no data, the router will trigger a lazy fetch to
retrieve the data that's expected to be there
([ref](https://github.com/vercel/next.js/blob/5adacb69126e0fd7dff7ebd45278c0dfd42f6116/packages/next/src/client/components/layout-router.tsx#L359-L370))
and then update the router cache once the data resolves
([ref](https://github.com/vercel/next.js/blob/5adacb69126e0fd7dff7ebd45278c0dfd42f6116/packages/next/src/client/components/layout-router.tsx#L399-L404)).

This is causing the router to get stuck in a loop: it'll fetch the data
for the cache node, send the data to the router reducer to merge it into
the existing cache nodes, skip merging that data in for `__DEFAULT__`
segments, and repeat.

### How?
We currently assign `__DEFAULT__` to have `notFound()` behavior when
there isn't a `default.tsx` component for a particular segment. This
makes it so that when loading a page that renders a slot without slot
content / a `default`, it 404s. But when performing a client-side
navigation, the intended behavior is different: we keep whatever was in
the `default` slots place, until the user refreshes the page, which
would then 404.

However, this logic is incorrect when triggering any of the above
mentioned cache node revalidation strategies: if we always skip applying
to the `__DEFAULT__` segment, slots will never properly handle reducer
actions that rely on making changes to their cache nodes.

This splits these different `applyPatch` functions: one that will apply
to the full tree, and another that'll apply to everything except the
default segments with the existing bailout condition.

Fixes #54173
Fixes #58772
Fixes #54723
Fixes #57665

Closes NEXT-1706
Closes NEXT-1815
Closes NEXT-1812
Copy link
Contributor

This closed issue has been automatically locked because it had no new activity for 2 weeks. If you are running into a similar issue, please create a new issue with the steps to reproduce. Thank you.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Dec 30, 2023
agustints pushed a commit to agustints/next.js that referenced this issue Jan 6, 2024
…ercel#59585)

### What?
There are a bunch of different bugs caused by the same underlying issue,
but the common thread is that performing any sort of router cache update
(either through `router.refresh()`, `revalidatePath()`, or `redirect()`)
inside of a parallel route would break the router preventing subsequent
actions, and not resolve any pending state such as from `useFormState`.

### Why?
`applyPatch` is responsible for taking an update response from the
server and merging it into the client router cache. However, there's
specific bailout logic to skip over applying the patch to a
`__DEFAULT__` segment (which corresponds with a `default.tsx` page).
When the router detects a cache node that is expected to be rendered on
the page but contains no data, the router will trigger a lazy fetch to
retrieve the data that's expected to be there
([ref](https://github.com/vercel/next.js/blob/5adacb69126e0fd7dff7ebd45278c0dfd42f6116/packages/next/src/client/components/layout-router.tsx#L359-L370))
and then update the router cache once the data resolves
([ref](https://github.com/vercel/next.js/blob/5adacb69126e0fd7dff7ebd45278c0dfd42f6116/packages/next/src/client/components/layout-router.tsx#L399-L404)).

This is causing the router to get stuck in a loop: it'll fetch the data
for the cache node, send the data to the router reducer to merge it into
the existing cache nodes, skip merging that data in for `__DEFAULT__`
segments, and repeat.

### How?
We currently assign `__DEFAULT__` to have `notFound()` behavior when
there isn't a `default.tsx` component for a particular segment. This
makes it so that when loading a page that renders a slot without slot
content / a `default`, it 404s. But when performing a client-side
navigation, the intended behavior is different: we keep whatever was in
the `default` slots place, until the user refreshes the page, which
would then 404.

However, this logic is incorrect when triggering any of the above
mentioned cache node revalidation strategies: if we always skip applying
to the `__DEFAULT__` segment, slots will never properly handle reducer
actions that rely on making changes to their cache nodes.

This splits these different `applyPatch` functions: one that will apply
to the full tree, and another that'll apply to everything except the
default segments with the existing bailout condition.

Fixes vercel#54173
Fixes vercel#58772
Fixes vercel#54723
Fixes vercel#57665

Closes NEXT-1706
Closes NEXT-1815
Closes NEXT-1812
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
bug Issue was opened via the bug report template. linear: next Confirmed issue that is tracked by the Next.js team. locked
Projects
None yet
Development

Successfully merging a pull request may close this issue.

5 participants