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

clientLoader revalidate even though shouldRevalidate returns false #12607

Open
prxg22 opened this issue Dec 21, 2024 · 17 comments · May be fixed by #13221
Open

clientLoader revalidate even though shouldRevalidate returns false #12607

prxg22 opened this issue Dec 21, 2024 · 17 comments · May be fixed by #13221
Assignees
Labels

Comments

@prxg22
Copy link

prxg22 commented Dec 21, 2024

I'm using React Router as a...

framework

Reproduction

  1. Go to this stackblitz
  2. On the reproduction browser, navigate to /main/view
  3. Click the toggle theme button
  4. Check the child clientLoader count increase

System Info

System:
    OS: macOS 14.6.1
    CPU: (8) arm64 Apple M1
    Memory: 129.34 MB / 8.00 GB
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 20.18.1 - /usr/local/bin/node
    Yarn: 1.22.19 - /usr/local/bin/yarn
    npm: 10.8.2 - /usr/local/bin/npm
    pnpm: 8.15.7 - /usr/local/bin/pnpm
  Browsers:
    Chrome: 131.0.6778.205
    Safari: 17.6
  npmPackages:
    @react-router/dev: ^7.1.0 => 7.1.0 
    @react-router/node: ^7.1.0 => 7.1.0 
    @react-router/serve: ^7.1.0 => 7.1.0 
    react-router: ^7.1.0 => 7.1.0 
    vite: ^5.4.11 => 5.4.11

Used Package Manager

npm

Expected Behavior

As reported in remix-run/remix#10234, single fetch feature introduced an unexpected behavior on clientLoader revalidation when using ssr = false strategy.

When a child route's shouldRevalidate function returns false, I expected that this route's clientLoader would not be triggered after a fetcher's submission in a parent route.

In my example / route has a fetcher.Form, that when submitted, should run its clientAction and revalidate that route's clientLoader, but routes /main and /main/view should not be revalidate, since their shouldRevalidate function will return false for that form submission.

Actual Behavior

After a fetcher submission in a parent route, child clientLoaders are being revalidated, even though their route's shouldRevalidate returns false.

In my example, you can check /main and /main/view counters increasing after each action dispatched by the parent route's fetcher.

@prxg22 prxg22 added the bug label Dec 21, 2024
@aland-x
Copy link

aland-x commented Dec 22, 2024

Reported this also in #12551 unfortunately I think @timdorr misunderstood the issue. I hope this makes it more clear.

@lukejagodzinski
Copy link

lukejagodzinski commented Jan 12, 2025

I have this issue as well. I think it was fine in RR 7.0 but got broken in 7.1. However, my use case was a little bit different. In RR 7.0, when action failed with error 422 or whatever other 4XX, it didn't revalidate. Now it revalidates. So I thought I will fix it with the shouldRevalidate and then I discover this bug and I see I'm not the only one. The question also is, should it revalidate on error? I think, I heard in one of the streams that it will not revalidate on action error.

@matheuscscp
Copy link

@timdorr Can you please look into this? 🙏 😸

@lukejagodzinski
Copy link

I've just checked code and in deed it shouldn't revalidate on 4XX or 5XX. You can see it here:

let actionStatus = pendingActionResult
? pendingActionResult[1].statusCode
: undefined;
let shouldSkipRevalidation = actionStatus && actionStatus >= 400;

@prxg22
Copy link
Author

prxg22 commented Jan 12, 2025

@lukejagodzinski according to this doc, it should not revalidate if you throw or return a 4xx/5xx Response.

@miniplus
Copy link

This is even happening without submissions.

// route: /parent
export function clientLoader() {
    console.log('parent clientLoader ran')
}

export default function Parent() {
    return (
        <>
            <Link to='./child'>Child</Link>
            <Outlet />
        </>
    )
}

// route: /parent/child
export default function Child() {
    return (
        <>
            <Link to='..'>Back</Link>
            <p>Child content</p>
        </>
    )
}

Whenever navigating to the child route, the parent clientLoader is running.
When navigating from the child back to the parent it is not.

Adding shouldRevalidate to the Parent route as follows has no effect and still allows the Parent clientLoader to run.

export const shouldRevalidate = ({
  nextUrl,
  defaultShouldRevalidate,
}) => {
  if (nextUrl.pathname === "/parent/child") {
    console.log("shouldRevalidate: false");
    return false;
  }

  return defaultShouldRevalidate;
};

@TimurTimergalin
Copy link

TimurTimergalin commented Feb 14, 2025

I encountered this issue as well. It seems like shouldRevalidate has no effect on clientLoader whatsoever. An example as simple as this:

// routes.ts

export default [
    layout("layouts/layout.tsx", [
        route("/route1", "routes/route1.tsx"),
        route("/route2", "routes/route2.tsx")
    ])
] satisfies RouteConfig;
// layouts/layout.tsx

export async function clientLoader() {
    console.log("Loader called")
}

export function shouldRevalidate() {
    console.log("shouldRevalidate called")
    return false
}

export default () => <Outlet/>
// routes/route1.tsx

export default () => (
    <>
        <p>Route 1</p>
        <Link to={"/route2"}>To route 2</Link>
    </>
)
// routes/route2.tsx

export default () => (
    <>
        <p>Route 2</p>
        <Link to={"/route1"}>To route 1</Link>
    </>
)

has this problem: when navigating between /route1 and /route2, both shouldRevalidate and clientLoader of parent layout are called, even though shouldRevalidate returns false. Adding shouldRevalidate to children modules does not work either.

@lukejagodzinski
Copy link

Can we get at least some acknowledgment that someone is working on it? It's pretty serious issue. It's not just app doing more work unnecessarily. It's also causing the action return data to be abandoned because of revalidation. If I don't store it in some state then it's gone and I have no info about the action error or return value. It basically makes app unusable. I'm really surprised that this error doesn't get more attention.

Can we somehow help? It's not like I just only want to complain but maybe we could actively solve this issue together? If someone from the Remix team points us to the source of issue then we might be able to contribute.

@MadsBuchmann
Copy link

Can we get at least some acknowledgment that someone is working on it? It's pretty serious issue. It's not just app doing more work unnecessarily. It's also causing the action return data to be abandoned because of revalidation. If I don't store it in some state then it's gone and I have no info about the action error or return value. It basically makes app unusable. I'm really surprised that this error doesn't get more attention.

Can we somehow help? It's not like I just only want to complain but maybe we could actively solve this issue together? If someone from the Remix team points us to the source of issue then we might be able to contribute.

Just want to chime in on this one. I would love helping out if possible! It's a serious issue in our application right now giving us +5 seconds load times on every page change - leading to some hacky solutions at the moment to circumvent the issue. Also, if there's any ideas for temporary workarounds i will happily test them out in our setup 😄

I also tried to downgrade to v7.0.0 and the issue also seems to persist here. For context we have ssr = false.

@TimurTimergalin
Copy link

TimurTimergalin commented Feb 19, 2025

@MadsBuchmann one workaround would be to prevent revalidation manually with sessionStorage:

const module = "A string, that is unique for each module"
const loaderDataPostfix = "loaderData"
const shouldRevalidatePostfix = "shouldRevalidate"

export function shouldRevalidate(args: ShouldRevalidateFunctionArgs) {
    const res = /* Your usual code. Make sure it is a boolean */
    sessionStorage.setItem(module + shouldRevalidatePostfix, String(res))  // Remove when the bug is fixed
    return res
}

export async function clientLoader(args: Route.ClientLoaderArgs) {
    if (sessionStorage.getItem(module + shouldRevalidatePostfix) !== "true") {  // Prevent revalidation, Remove when the bug is fixed
        return JSON.parse(sessionStorage.getItem(module + loaderDataPostfix)!)
    }
    const res = /* Your usual code - make sure it can be used with JSON.stringify*/
    sessionStorage.setItem(module + loaderDataPostfix, JSON.stringify(res))  // Remove when the bug is fixed
    return res
}   

This is somewhat hacky (and ultimately something react-router is created to replace), so I wish this bug is fixed soon. Hope the workaround will be useful though - it worked for me.

@MadsBuchmann
Copy link

@TimurTimergalin thanks! That's pretty clever. We're doing some very simple in-memory caching at the moment. Because we're always returning the same object, we're avoiding unnecessary re-renders in the application.

let cachedClientLoader: undefined | T; 

export function clientLoader(...): T {
    if (cachedClientLoader) return cachedClientLoader; 

    cachedClientLoader = /* usual code resulting in an object to be returned with type T*/
    return cachedClientLoader
}

export function shouldRevalidate(...) {
   const revalidate: bool = /* bool defining whether you want to revalidate*/
   if (revalidate) {
      cachedClientLoader = undefined; 
   }
}

@matheuscscp
Copy link

matheuscscp commented Feb 19, 2025

nice workaround, chaps!

but I'd really appreciate a proper fix here :)

I'm more than willing to contribute this fix myself, but I'm like a blinded person in a shooting spree :( if anybody has any pointers I'd be more than happy to open a PR and work on it 🙏

as a maintainer of another largely used open source project I fully appreciate that there's many things to look at and all help is always welcome 🌹

@lukejagodzinski
Copy link

OK, I actually have to take back what I said. My scenario is a little bit different from what you guys experience and it's not critical for my app working. Today, I was creating reproduction repository for this issue and noticed that it's similar but not exactly the same issue. There is still problem with the unnecessary revalidation happening but I can easily fix it with shouldRevalidate export where you can't. If anyone is curious I filed a bug report: #13062

@MadsBuchmann
Copy link

MadsBuchmann commented Mar 5, 2025

I think i may have discovered an additional detail regarding this issue or perhaps something in my understanding of how revalidation works is wrong.

I have two routes:

  • $userName.($country): here shouldRevalidate always returns false
  • $userName.($country).$municipality: here shouldRevalidate always returns true

If i go from /mads/denmark to /mads/sweden then it never revalidates i.e. works as i would expect! Nice!

However if i change between municipalities then the route $userName.($country) starts revalidating again for example by changing from /mads/denmark/aarhus to mads/denmark/copenhagen. Not working as expected, as i would like the route $userName.($country) to never revalidate and $userName.($country).$municipality to revalidate.

But perhaps my understanding is off, if so, please correct me.

@brophdawg11 brophdawg11 self-assigned this Mar 12, 2025
@UltraWelfare
Copy link

@TimurTimergalin thanks! That's pretty clever. We're doing some very simple in-memory caching at the moment. Because we're always returning the same object, we're avoiding unnecessary re-renders in the application.

let cachedClientLoader: undefined | T;

export function clientLoader(...): T {
if (cachedClientLoader) return cachedClientLoader;

cachedClientLoader = /* usual code resulting in an object to be returned with type T*/
return cachedClientLoader

}

export function shouldRevalidate(...) {
const revalidate: bool = /* bool defining whether you want to revalidate*/
if (revalidate) {
cachedClientLoader = undefined;
}
}

I quickly wrote a Vite Plugin that does this solution, to avoid modifying all the files (then having to revert the changes once the bug is fixed). It does the transform "on-the-fly".

The code is not very well written but it seems to work for the most part.
Note: It won't work if you use return undefined on your loaders.

import type {Plugin} from "vite";
import {parse, print, types} from "recast";
import {visit} from "ast-types";
import babelTsParser from "recast/parsers/babel-ts";

const b = types.builders;

export function VitePluginRevalidate(): Plugin {
    return {
        name: "vite-plugin-revalidate",
        enforce: "pre",
        transform(code, id) {
            if (!id.endsWith(".tsx") || id.includes('node_modules')) return null;

            let ast = parse(code, {
                parser: babelTsParser
            });

            let hasShouldRevalidate = false;
            let hasClientLoader = false;

            visit(ast, {
                visitFunctionDeclaration(path) {
                    if (path.node.id?.name === "shouldRevalidate") {
                        hasShouldRevalidate = true;

                        const body = path.node.body.body;

                        const generatedFunction = b.functionDeclaration(
                            b.identifier("__generated"),
                            [],
                            b.blockStatement(body)
                        );

                        path.node.body.body = [
                            generatedFunction,

                            b.variableDeclaration("const", [
                                b.variableDeclarator(b.identifier("revalidate"), b.callExpression(b.identifier("__generated"), []))
                            ]),
                            b.ifStatement(
                                b.identifier("revalidate"),
                                b.blockStatement([
                                    b.expressionStatement(
                                        b.assignmentExpression("=", b.identifier("cachedClientLoader"), b.identifier("undefined"))
                                    )
                                ])
                            )
                        ];
                    } else if (path.node.id?.name === "clientLoader") {
                        hasClientLoader = true;

                        const body = path.node.body;
                        const isAsync = path.node.async;

                        const generatedFunction = b.functionDeclaration(
                            b.identifier("__generated"),
                            [],
                            b.blockStatement(body.body)
                        );
                        if (isAsync) {
                            generatedFunction.async = true;
                        }

                        const callExpr = b.callExpression(b.identifier("__generated"), []);

                        body.body = [
                            b.ifStatement(
                                b.binaryExpression(
                                    "!==",
                                    b.identifier("cachedClientLoader"),
                                    b.identifier("undefined")
                                ),
                                b.blockStatement([
                                    b.returnStatement(b.identifier("cachedClientLoader"))
                                ])
                            ),
                            generatedFunction,
                            b.variableDeclaration("const", [
                                b.variableDeclarator(b.identifier("result"), isAsync ? b.awaitExpression(callExpr) : callExpr)
                            ]),
                            b.expressionStatement(
                                b.assignmentExpression("=", b.identifier("cachedClientLoader"), b.identifier("result")),
                            ),
                            b.returnStatement(b.identifier("cachedClientLoader"))
                        ]

                    }
                    return false;
                }
            });


            if (!hasShouldRevalidate || !hasClientLoader) return null;

            ast.program.body.unshift(
                b.variableDeclaration("let", [b.variableDeclarator(b.identifier("cachedClientLoader"))])
            );

            return {
                code: print(ast).code,
                map: null,
            };
        }
    };
}

Save it in the project folder as vite-revalidate-plugin.ts and then update your vite.config.ts to include it

export default defineConfig({
    plugins: [
        VitePluginRevalidate(),
        reactRouter(),
        tsconfigPaths()
    ],
    ...

@chapmanio
Copy link

I'm struggling with this issue as well, the only way I've been able to resolve it is by defining shouldRevalidate on every "layer" up from (and including) root, e.g.

// Routes
export default [
  index('./routes/index.tsx'),

  route('entity', './routes/entity/entityLayout.tsx', [
    index('./routes/entity/entityIndex.tsx'),

    route(':entityId', './routes/entity/entity.tsx'),
  ]),

  route('*', './routes/notFound.tsx'),
] satisfies RouteConfig;

I have to export shouldRevalidate in all of these files for it to behave:

  • root.tsx
  • entityLayout.tsx
  • entityIndex.tsx
  • entity.tsx

My expectation (hope?) was that I would only need to apply this on entityLayout.tsx and it would "bubble up" from the routes beneath it, especially as this is the only file that has a clientLoader export. Have I understood this wrong?

@brophdawg11
Copy link
Contributor

brophdawg11 commented Mar 13, 2025

Hey folks - for SPA mode (ssr:false without prerender), this should have been fixed in 7.2.0 (and Remix 2.16.0) where we removed the unintentional single fetch revalidation behavior being applied to SPA mode (#12948, remix-run/remix#10479).

For ssr:true apps, this looks like a bug and will be fixed by #13221 in the next release. I'll also backport that fix to a Remix v2 release to help with adoption of single fetch in preparation for upgrading to RRv7.

Update: Remix backport PR: remix-run/remix#10527

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging a pull request may close this issue.

10 participants