在remix中谨慎使用useFetcher进行poll

问题概述

今天在开发时遇到了一个有趣的问题。我在实现一个异步任务提交和状态轮询的功能时,使用了两个 useFetcher:

  • 第一个 fetcher 用于任务提交,使用 submit 方法(这样可以触发页面 credit 的自动刷新)
  • 第二个 fetcher 用于状态轮询,使用 load 方法(因为不需要页面刷新数据)

结果发现了一个奇怪的现象:第一个任务能正常工作,但提交第二个任务后,轮询返回的却始终是第一个任务的数据, 后面也是返回的永远是第一个任务的结果.

后面发现这个问题其实是和 fetcher 的加载有关, 在 remix 某个版本之后, fetcher 的 load 也会随着 submit 之后的页面数据更新而更新. 参考

fetcher.load’s revalidate by default after action submissions and explicit revalidation requests via useRevalidator. Because fetcher.load loads a specific URL they don’t revalidate on changes to route param or URL search param. You can use shouldRevalidate to optimize which data should be reloaded.

useFetcher 简介

useFetcher 是 Remix 提供的一个强大的 Hook,用于处理非导航场景下的数据获取和提交。它的主要特点是:

  • 可以在不触发完整页面导航的情况下进行数据操作
  • 提供了 Form 组件用于数据提交
  • 支持 load 和 submit 两种数据操作方式
  • 可以使用 state 获得当前 fetcher 状态 方便实现 Pending UI 和 Optimistic UI

问题复现

我简化了我的代码, 假设有这两个代码文件:

test.tsx:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { data, useFetcher } from "@remix-run/react";
import { useEffect, useState } from "react";

export const loader = async ({ request }: LoaderFunctionArgs) => {
console.log("call test loader");
return Response.json({ hello: 1 });
};

export const action = async ({ request }: ActionFunctionArgs) => {
return data({ taskId: crypto.randomUUID() });
};

export default function Test() {
const submitFetcher = useFetcher<typeof action>();
const pollFetcher = useFetcher<{ taskId: string }>();
const [taskId, setTaskId] = useState<string>("");

useEffect(() => {
if (!submitFetcher.data) {
return;
}
setTaskId(submitFetcher.data.taskId);
}, [submitFetcher.data]);

useEffect(() => {
if (!taskId) {
return;
}
console.log("call useEffect");
pollFetcher.load(`/test/status?taskId=${taskId}`);
// const id = taskId;
// const interval = setInterval(() => {
// console.log("interval called");
// pollFetcher.load(`/test/status?taskId=${id}`);
// }, 1000);
// return () => {
// console.log("clean interval");
// clearInterval(interval);
// };
}, [taskId]);

useEffect(() => {
if (!pollFetcher.data) {
return;
}
// stop interval
setTaskId("");
}, [pollFetcher.data]);

return (
<div className="flex flex-col gap-6 p-10">
<submitFetcher.Form method="post">
<button
type="submit"
className="bg-green-200 p-2 rounded-xl border-b border-red-300 shadow-md"
>
generate task
</button>
</submitFetcher.Form>

<div className="flex gap-2">
taskId : {submitFetcher.data?.taskId ?? ""}
</div>
<p>poll result : {pollFetcher.data?.taskId ?? ""}</p>
</div>
);
}

test.status.tsx

1
2
3
4
5
6
7
import { LoaderFunctionArgs } from "@remix-run/node";

export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
console.log("call status. url:", url);
return { taskId: url.searchParams.get("taskId") };
}

这里就是简化了我的实际场景,假设每次提交都会返回一个不同的 taskId,返回之后进行 poll, poll 到结果之后设置 taskId 为空字符串去清理掉 interval,完成一个任务的提交-poll-获得结果结束,这里把 poll 注释掉了,先看看这个情况下会怎么样.

然后点击两次让他模拟两个任务

  • 第一次任务 id: 141f56eb-a7ac-41b4-99bd-9a6bbfafedfe
  • 第二次任务 id: 708e98f1-84e3-4b1f-9fbe-0db82498147c

第二次任务 id poll 的时候返回没有问题. 但是我们在这里又看到了一个被取消的请求,这个就是 poll fetcher 发出的,他的任务 id 还是第一个.

所以在第二次任务 submit 之后,poll fetcher 立即更新自己的状态,此时他的路径是第一次的,所以他又用第一次的任务发了请求,只不过useEffect立即触发,导致并发请求取消了第一次的请求.

而我的代码里用了 interval,初始会慢几秒,把注释的逻辑打开,让 interval 生效.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
useEffect(() => {
if (!taskId) {
return;
}
console.log("call useEffect");
// pollFetcher.load(`/test/status?taskId=${taskId}`);
const id = taskId;
const interval = setInterval(() => {
console.log("interval called");
pollFetcher.load(`/test/status?taskId=${id}`);
}, 1000);
return () => {
console.log("clean interval");
clearInterval(interval);
};
}, [taskId]);

再次运行, 点击两次:

复现了… … 他只会去请求第一次任务的 id.

问题分析

这个问题的核心在于 Remix 的 revalidate 机制。在较新版本的 Remix 中,当进行 submit 操作后(不管是 fetcher.submit 还是 Form/ fetcher.Form 的 submit) 执行后,所有的 fetcher 的 load 操作 都会进行 revalidate。这个机制本意是保持数据一致性,但在轮询场景下会导致问题

  1. 第一个任务提交后,pollFetcher 开始轮询, 正常完成 此时 pollFetcher 内部状态里记录了第一次任务的查询 url(/status/test?taskId=第一次任务)
  2. 第二个任务提交时,submitFetcher 的提交触发了页面更新, pollFetcher 的 load 的请求会被默认重新加载
  3. pollFetcher 被重新更新,但此时内部仍然保持着第一个任务的 URL, 而此时 useEffect 里用了 interval 导致第二个任务的查询请求会延后执行
  4. pollFetcher 更新后的结果返回了 taskId 被置为空数组 interval 被清理
  5. 由于 interval 被清理,新的轮询请求无法发出
  6. 结果就是永远只能看到第一个任务的数据

如果这个时间设置比较短或者任务查询的接口耗时超过了 interval 设置的间隔时间,这 bug 还不会出现,比较幽默了.

解法

要 fix 的话需要在 poll 结束之后就立即把 poll fetcher 的状态重置为 init(这个 api 没提供), 或者禁止这个 poll fetcher 的 revalidate 行为(这个只能在 route 层面禁用掉整个/test/status 的 api 调用,没法针对一个 fetcher).

当我还在思考单独禁用某个 fetcher 的 revalidation 似乎比较常用但官方为什么不提供的时候, 找到了这个: fetcher revalidation after actions, 原来早就有大佬想到了, 只不过当时 fetcher 是不会被 revalidate 的, 后面 remix 改成了会自动 revalidate, 不过大佬说的加个参数… …是一直没加

那我最后是怎么修的呢… 很简单, 把 poll fetcher 换成个 fetch 调用就行 😂😂😂
或者在test.status.tsx里增加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export const shouldRevalidate: ShouldRevalidateFunction = ({
actionResult,
currentParams,
currentUrl,
defaultShouldRevalidate,
formAction,
formData,
formEncType,
formMethod,
nextParams,
nextUrl,
}: ShouldRevalidateFunctionArgs) => {
return false;
};

让这个 loader 永远不要被重新加载

参考资料

useFetcher

shouldRevalidate