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

Updating search params does not trigger suspense fallback or loading.tsx

Open dclark27 opened this issue 2 years ago • 63 comments

Verify canary release

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

Provide environment information

Operating System:
      Platform: linux
      Arch: x64
      Version: #22 SMP Tue Jan 10 18:39:00 UTC 2023
    Binaries:
      Node: 18.14.2
      npm: 9.5.0
      Yarn: 1.22.19
      pnpm: N/A
    Relevant Packages:
      next: 13.4.12
      eslint-config-next: 13.4.12
      react: 18.2.0
      react-dom: 18.2.0
      typescript: 5.0.4
    Next.js Config:
      output: N/A

Which area(s) of Next.js are affected? (leave empty if unsure)

App Router, Routing (next/router, next/navigation, next/link)

Link to the code that reproduces this issue or a replay of the bug

https://codesandbox.io/p/sandbox/muddy-glade-hn895s

To Reproduce

  1. Create a component which uses search parameters in fetching data
  2. Use a link to change the query param and load new data based on the search param
  3. Observe that loading.tsx fallback does not invoke and the page hangs while new data is loaded

Describe the Bug

Updating search params does not trigger suspense fallback or loading.tsx

Expected Behavior

Fetching new data based on new search params trigger suspense fallbacks or loading.tsx

Which browser are you using? (if relevant)

No response

How are you deploying your application? (if relevant)

No response

dclark27 avatar Aug 03 '23 18:08 dclark27

Hello, i don't know if this is an expected behavior or not, but i've stumbled on this behvior a little while ago and traced it back to React ( link : https://react.dev/reference/react/Suspense#resetting-suspense-boundaries-on-navigation ).

The thing is that for Suspense fallbacks (and loading.tsx which is just another Suspense) to show, it needs to have a different key setup ( <Suspense key={YOUR_KEY} />), it seems like next do not include the searchParams in the key of a route (for loading.tsx), so navigating to the same route do not retrigger the suspense fallback.

My solution was just to include the searchParams into the key of the Suspense fallback and it worked. Something like this :

export default function Page(props: {
  searchParams?: Record<string, string | undefined>;
}) {
  const keyString = `search=${props.searchParams?.search}`; //  <-- Construct key from searchParams 
  return (
    <section>
      <SearchInput />

      {props.searchParams?.search && (
        <React.Suspense
          // pass key to Suspense
          key={keyString} 
          fallback={
            <ul>
              <ProductCardSkeleton />
              <ProductCardSkeleton />
              <ProductCardSkeleton />
              <ProductCardSkeleton />
            </ul>
          }
        >
           // <ProductList> is an async React component that do the fetching 
          <ProductList name={props.searchParams?.search} />
        </React.Suspense>
      )}
    </section>
  );

This won't work for loading.tsx file though as that file do not take searchParams as props.

Fredkiss3 avatar Aug 04 '23 00:08 Fredkiss3

Thanks for the response @Fredkiss3 ! I'm sure it will help someone.

Unfortunately for my scenario that won't trigger the suspense boundary because I am loading new data via the a react server component. I would think the loading.tsx file would hit the fallback since that is the suspense boundary of my page.tsx.

dclark27 avatar Aug 07 '23 18:08 dclark27

@dclark27

The key thing seems to be how it is supposed to work : https://twitter.com/sebmarkbage/status/1688622875702345728?s=46

Normally if you want to trigger suspense boundaries, you have to fetch your data in a subcomponent not the page directly, this is fairly easy as you can pass props from the page to another component.

Fredkiss3 avatar Aug 07 '23 19:08 Fredkiss3

@Fredkiss3

Thanks for the tweet thread, I hopped in the replies. Since NextJS is handling the boundaries, there should be a way to re-trigger the suspense boundary, whether that's giving loading.tsx knowledge of the params, or having search params be included in the keys.

I'm not sure what @sebmarkbage means that search params are generally used differently.

If I'm not using them correctly in my bug, I can refactor to use params for my pagination. But having something like app/page/[pageNumber]/page.tsx feels overkill for paging through a table (especially when the result set can vary in length).

edit:

I've updated the sandbox to include a refactored version of pagination using [pageNumber].

dclark27 avatar Aug 07 '23 20:08 dclark27

One thing @dclark27 CodeSandbox does not support streaming, so suspense won't work as expected.

Fredkiss3 avatar Aug 07 '23 22:08 Fredkiss3

I've encountered the same issue. If the API has a cold start when performing a search, I would like to show the "loading" state from loading.tsc file till the current request is finished.

razvanbretoiu avatar Aug 08 '23 06:08 razvanbretoiu

@Fredkiss3 did you test it in production too ? because it worked for me in dev mode but in production it didn't

MelancholYA avatar Oct 08 '23 11:10 MelancholYA

I've got the same issue Suspense isn't being triggered for searchParams at all.

@dclark27 did you find a solution for this problem ?

Jordaneisenburger avatar Oct 23 '23 13:10 Jordaneisenburger

Got the same problem. I tried @Fredkiss3 's solution by passing a key to <Suspense> but it didn't work, not even in dev stage.

anthonyiu avatar Nov 24 '23 00:11 anthonyiu

Having the same problem. In a statically generated component, I have a Suspense boundary with a param-based key over a RSC that renders data based on query parameters from page.tsx. It also has noStore() to make the RSC dynamic. Fallback does render when query parameters change in development mode but not in production. Don't really know what to do from this point.

SarahLightBourne avatar Nov 24 '23 23:11 SarahLightBourne

Hi all, I tried a workaround and it works for me. Please try to convert your page.tsx to a client component using "use client" , get searchParams by useSearchParams() instead of the page props and wrap the server component that fetch data in <Suspense> as follows.

Hope it helps. Thanks.

page.tsx

"use client";

import { Suspense } from "react";
import BlogSkeleton from "../../../components/ui/BlogSkeleton";
import AllBlogPosts from "./AllBlogPosts";
import { useSearchParams } from "next/navigation";
import BlogLayout from "../../../components/section/BlogLayout";

const Blog = () => {

  const searchParams = useSearchParams();
  const searchParamsValue = searchParams.has("type")
    ? searchParams.get("type")
    : "all";


  const keyString = searchParamsValue ? searchParamsValue : "all";

  return (
    <BlogLayout>
      <Suspense key={`type=${keyString}`} fallback={<BlogSkeleton />}>
        <AllBlogPosts searchParamsValue={keyString} />
      </Suspense>
    </BlogLayout>
  );
};
export default Blog;

anthonyiu avatar Nov 27 '23 10:11 anthonyiu

Hi all, I tried a workaround and it works for me. Please try to convert your page.tsx to a client component using "use client" , get searchParams by useSearchParams() instead of the page props and wrap the server component that fetch data in <Suspense> as follows.

Hope it helps. Thanks.

page.tsx

"use client";

import { Suspense } from "react";
import BlogSkeleton from "../../../components/ui/BlogSkeleton";
import AllBlogPosts from "./AllBlogPosts";
import { useSearchParams } from "next/navigation";
import BlogLayout from "../../../components/section/BlogLayout";

const Blog = () => {

  const searchParams = useSearchParams();
  const searchParamsValue = searchParams.has("type")
    ? searchParams.get("type")
    : "all";


  const keyString = searchParamsValue ? searchParamsValue : "all";

  return (
    <BlogLayout>
      <Suspense key={`type=${keyString}`} fallback={<BlogSkeleton />}>
        <AllBlogPosts searchParamsValue={keyString} />
      </Suspense>
    </BlogLayout>
  );
};
export default Blog;

Unfortunately, this solution wastes all the benefits of using server components.

arklanq avatar Nov 27 '23 11:11 arklanq

Hi all, I tried a workaround and it works for me. Please try to convert your page.tsx to a client component using "use client" , get searchParams by useSearchParams() instead of the page props and wrap the server component that fetch data in <Suspense> as follows. Hope it helps. Thanks. page.tsx

"use client";

import { Suspense } from "react";
import BlogSkeleton from "../../../components/ui/BlogSkeleton";
import AllBlogPosts from "./AllBlogPosts";
import { useSearchParams } from "next/navigation";
import BlogLayout from "../../../components/section/BlogLayout";

const Blog = () => {

  const searchParams = useSearchParams();
  const searchParamsValue = searchParams.has("type")
    ? searchParams.get("type")
    : "all";


  const keyString = searchParamsValue ? searchParamsValue : "all";

  return (
    <BlogLayout>
      <Suspense key={`type=${keyString}`} fallback={<BlogSkeleton />}>
        <AllBlogPosts searchParamsValue={keyString} />
      </Suspense>
    </BlogLayout>
  );
};
export default Blog;

Unfortunately, this solution wastes all the benefits of using server components.

I somewhat agree with you. However, given that my app is kind of light-weighted and it's acceptable for me to shift the rendering to client side.

anthonyiu avatar Nov 28 '23 10:11 anthonyiu

@anthonyiu if you specify the whole page as client component, then how can you fetch data in AllBlogPosts as it's no longer a server component? Or do you mean you fetch data manually using API?

SarahLightBourne avatar Jan 18 '24 01:01 SarahLightBourne

hey did anybody found the solution?

abhishek2393 avatar Feb 02 '24 13:02 abhishek2393

hey did anybody found the solution?

use key in suspense component and the key should be unique, for my case, the key was api's url with the searchparams in string format

siduck avatar Feb 02 '24 14:02 siduck

Adding the key in Suspense worked for me, but then turned the entire route dynamic. I was hoping to use partial prerendering, but that only works if I remove they key from Suspense, which then causes the fallback to not trigger lol.

const Page = ({ searchParams }) => {
  return (
    <>
      <Suspense key={JSON.stringify(searchParams)}>
        <DynamicContent searchParams={searchParams} />
      </Suspense>
      <StaticContent />
    </>
  )
}

This works visually as expected, but causes the whole route to be dynamic

Edit: This only worked locally, and did not work when deployed.

christopher-caldwell avatar Feb 09 '24 01:02 christopher-caldwell

For people that are still not able to get this to work correctly, here is what worked for me.

Some context before we move on. The react documentation states the following:

Suspense lets you display a fallback until its children have finished loading.

So with that in mind, make sure that the following is true:

1 - The actual data loading is happening in the child, and not the same component where the Suspense component lives. 2 - Make sure that you assign the key to the Suspense component, and not the child component itself.

NOTES:

Although I am unsure of the consequences of adding a key to a Suspense component, this seemed to work well and solved the issue I was having. Maybe this is an anti pattern for Suspense specifically? Time will tell.

Both of the components shown below are server components. I am unsure if applying the same strategy would work if the child component was a client component, but from the react documentation it states the following:

Only Suspense-enabled data sources will activate the Suspense component. They include:

Data fetching with Suspense-enabled frameworks like Relay and Next.js Lazy-loading component code with lazy Reading the value of a Promise with use

So it should work for client components, if the conditions above are met.

Here is an example:

// page.tsx
// note that this is a server component

import { Suspense } from "react";
import { ListingGrid } from "@/app/_listing/ListingGrid";
import ListingGridFilter from "@/app/_listing/ListingGridFilter";
import { ListingSearchParams } from "@/app/lib/listing/data";
import ListingGridSkeleton from "@/app/_listing/ListingGridSkeleton";

export default async function Home({
  searchParams,
}: {
  searchParams: ListingSearchParams;
}) {
  const beds = searchParams?.bedrooms ?? "0";
  const baths = searchParams?.bathrooms ?? "0";
  const park = searchParams?.parking ?? "0";
  const price = searchParams?.price ?? "0";

  return (
    <main className="flex -h-screen flex-col items-center justify-between p-24">
      <ListingGridFilter />
      <Suspense
        key={beds + baths + park + price}
        fallback={<ListingGridSkeleton />}
      >
        <ListingGrid searchParams={searchParams} /> // <--- Data loading is happening here
      </Suspense>
    </main>
  );
}
// ListingGrid.tsx
// note that this is also a server component

import {
  ListingData,
  ListingSearchParams,
  ListingType,
} from "@/app/lib/listing/data";
import { ListingCard } from "./ListingCard";
import { unstable_noStore } from "next/cache";

export async function ListingGrid({
  searchParams,
}: {
  searchParams: ListingSearchParams;
}) {
   // This is just to force requests to be fetched every time
   // so you can see the components loading.
  unstable_noStore();
  const listing: Array<ListingType> = await ListingData(searchParams);

  if (!listing.length) {
    return <p className="text-xl">No listing matches your criteria</p>;
  }

  return (
    <div className="w-3/4 grid grid-cols-1 2xl:grid-cols-4 xl:grid-cols-3 lg:grid-cols-2 gap-y-12 gap-x-4">
      {listing.map((listingItem) => (
        <ListingCard key={listingItem.Id} listing={listingItem} />
      ))}
    </div>
  );
}

ubirajaramneto avatar Feb 12 '24 19:02 ubirajaramneto

I made an in depth reproduction of my use case here.

Repo: https://github.com/christopher-caldwell/ppr-loading-suspense-demo Live URL: https://ppr-loading-suspense-demo.vercel.app

Adding a key to suspense works locally, but NOT hosted on Vercel.

I haven't tried unstable_noStore, so I will give that a go. The docs say:

Partial Prerendering does not yet apply to client-side navigations. We are actively working on this.

So I cannot really tell if this behavior is intentional or not.

christopher-caldwell avatar Feb 12 '24 22:02 christopher-caldwell

@ubirajaramneto Adding unstable_noStore did not change the result for me. It works local, but not hosted. Maybe my particular issue is with Vercel, and not Next itself.

With Suspense key: https://ppr-loading-suspense-demo.vercel.app/?page=1 Without Suspense key: https://ppr-loading-suspense-demo.vercel.app/without-suspense-key?page=1

I mostly want to know if this behavior is considered intentional for where the dev team is in the development process of PPR. I understand that features will trickle in, but is this supposed to be happening? At least for now.

Edit: yarn build and then yarn start on my local machine, and this works as expected. I think my issue might be with Vercel in their hosting.

christopher-caldwell avatar Feb 12 '24 22:02 christopher-caldwell

@christopher-caldwell I have uploaded the whole project in my github for you to see, also just to make sure, I have deployed the application to vercel and it is working the same way as it is working on localhost.

Demo: https://nextjs-listings-app.vercel.app/ Repo: https://github.com/ubirajaramneto/nextjs-listings-app

After reviewing your code, many things popped in my head as what the culprit would be, but I am not confident enough to take a bet.

But if I was forced to guess, I would try the following:

1 - remove the wait promise that you wrapped your api call around 2 - make sure that inside your suspense component, there is only one component (maybe react is not seeing the promise inside Suspense)

Try to read through my example and try applying some of the patterns you see there and see if anything works out.

ubirajaramneto avatar Feb 13 '24 13:02 ubirajaramneto

@ubirajaramneto Thanks for your suggestions. I appreciate you taking a look.

I have implemented them, but didn't seem to change anything: https://ppr-loading-suspense-demo-git-ubira-e4da36-christopher-caldwell.vercel.app/ and https://github.com/christopher-caldwell/ppr-loading-suspense-demo/blob/ubirajara-suggestions/app/page.tsx

Something to note, your project does not have the ppr experimental flag. I understand that I might be up a creek due to it being experimental, but wanted to point that out.

For the wait, it is just to simulate the network taking longer than expected. It doesn't wrap the call, wait is a function that is awaited inside the API call function. I removed it anyway just to be sure.

I put in a support ticket with Vercel, as this works when I run it on my computer with yarn start. We shall see what happens.

christopher-caldwell avatar Feb 13 '24 14:02 christopher-caldwell

Sorry @christopher-caldwell , my solution was not necessarily aiming at your problem, since I am not doing any pre-rendering in the example I provided. This example I posted was to point the general public on how to handle the suspense boundary not triggering, which was the problem I first faced and wanted to share my solution here so it can serve as a reference.

And regarding your await call, yes you are right, I might have expressed myself poorly on that instance.

If you like you can contact me directly and I'll be glad to throw some insight into your problem, just so we can keep this issue clean of parallel discussions.

Hope you can find a solution for this problem soon!

ubirajaramneto avatar Feb 13 '24 15:02 ubirajaramneto

Sorry @christopher-caldwell , my solution was not necessarily aiming at your problem, since I am not doing any pre-rendering in the example I provided. This example I posted was to point the general public on how to handle the suspense boundary not triggering, which was the problem I first faced and wanted to share my solution here so it can serve as a reference.

And regarding your await call, yes you are right, I might have expressed myself poorly on that instance.

If you like you can contact me directly and I'll be glad to throw some insight into your problem, just so we can keep this issue clean of parallel discussions.

Hope you can find a solution for this problem soon!

Interestingly, your solution worked perfectly for me locally. So I reached to Vercel, because they say that if your code is working locally, and on their platform, you should submit a support ticket.

The only blurb about PPR is that it doesn't support client side navigation, which is a bit ambiguous to me. So I'm not sure if my issue is because of PPR (which is fine, it's experimental) or Vercel is not supporting it or whatever the case is.

christopher-caldwell avatar Feb 13 '24 15:02 christopher-caldwell

If anyone else runs into this issue (specifically with PPR), using regular html anchor tags worked for me. You don't get all the benefits of the Next <Link/>, but it shows the loading fallback until the team can implement client side navigation.

I also heard back from Vercel. Even if it works locally, they say to take it up with Next since it's an experimental feature. It's not their problem, according to them (they were nice, I don't mean to imply they weren't).

christopher-caldwell avatar Feb 15 '24 20:02 christopher-caldwell

face the same issue, working fine on local build npm run build & npm run start but I am building the standalone build by setting the output = standalone in the next config file. and after deploying to the Google Cloud. an application does not work as expected and a route that uses the Suspense gives 500

if any one solve the issue, please share it can be helpful

vishaltyagi227 avatar Apr 12 '24 19:04 vishaltyagi227

Any updates from the next.js team from this? I would have assumed adding a loading.tsx file would mean that if the page is fetching more data on navigation change (search params changes etc) that the loading.tsx would be re-rendered?

JClackett avatar Jun 09 '24 13:06 JClackett

This really needs to be addressed, can't use server-side data fetching for a data-intensive chart now :(

AChangXD avatar Jun 11 '24 16:06 AChangXD

@AChangXD you can just put the data fetching into an async component that you then wrap in Suspense in your page.tsx file. You can then add the fallback to the suspense instead of using loading.tsx and it works. NOTE: if you're using search params to navigate you do have to put a key on the Suspense that changes, so that it actually triggers the fallback though for example:

  key={`${searchParams.query}${searchParams.otherParam}`}

JClackett avatar Jun 11 '24 16:06 JClackett

@AChangXD you can just put the data fetching into an async component that you then wrap in Suspense in your page.tsx file. You can then add the fallback to the suspense instead of using loading.tsx and it works. NOTE: if you're using search params to navigate you do have to put a key on the Suspense that changes, so that it actually triggers the fallback though for example:


  key={`${searchParams.query}${searchParams.otherParam}`}

Yeah I didn't realize the key needs to be dynamic, working now😃

AChangXD avatar Jun 11 '24 16:06 AChangXD