DrillLab

fetch 取数:loading、error 与竞态Fetching data: loading, error and race conditions

React困难 · Hard约 35 分钟~35 min本机跑Run it locally
§01

题面The problem

先把要求读完,再动手。Read every requirement before you start.

三个 state 已给好。写出 effect 和三个提前返回。 检查器会查 res.ok、清理函数、竞态防护和 AbortError 过滤。

The three states are given. Write the effect and the three early returns. The checker looks for res.ok, the cleanup function, the race protection and the AbortError filter.

验收标准Acceptance criteria
  • 三态:loading / error / 成功显示 name 与 emailThree states: loading, error, and success which shows name and email
  • fetch 只在网络层失败时 reject —— 404 / 500 要自己查 res.ok,错误文案带 HTTP 和状态码fetch only rejects when the network layer fails. Check res.ok yourself for 404 and 500, and put HTTP plus the status code in the error message
  • userId 变了要重新取,并且先回到 loading,不留上一个人的数据在屏幕上When userId changes, fetch again and go back to loading first, so the previous user's data never stays on screen
  • 竞态:旧请求晚回来不许覆盖新数据(effect 里立 ignore 开关,清理函数置 true)Race condition: an old response that arrives late must not overwrite newer data. Declare an ignore flag inside the effect and set it to true in the cleanup function
  • 切 userId / 卸载时用 AbortController 掐掉在飞的请求;AbortError 不是错误,不给用户看Use AbortController to cancel the in-flight request when userId changes or the component unmounts. An AbortError is not a real failure, so do not show it to the user

预计 35 分钟。这个数字是照「读完题就开始写、不查资料」估的 —— 第一次超时很正常,第二次要压进去。Budget 35 minutes. Overrunning on the first pass is normal; the second pass should fit.

§02

工作区Workspace

这道题在本机跑Run this one on your own machine

这道题要 stub fetch 才能测竞态,而 Sandpack 的测试环境拦不住 fetch(globalThis / window / self 都试过)。测试本身是对的 —— 在本机 vitest 下 9/9 通过。所以这里只给命令。Testing the race condition needs a stubbed fetch, and Sandpack's test environment cannot intercept fetch (globalThis, window and self were all tried). The tests themselves are fine — 9/9 under vitest locally. So you get the commands instead.

跑完自己对一遍期望输出,然后在下面打勾。这里不给假编辑器 —— 装个能跑的样子只会让你以为练过了。Compare the output yourself, then tick it off below. No fake editor here.

自己写出来、测试全绿之后再打勾。看懂答案不算。Tick this only after you wrote it yourself and the tests went green.
§03

展开讲解Walkthrough

下面是《变式三 · fetch 取数:loading、error 与竞态》那一节的原文 —— 和课程里是同一份内容,不是另写的摘要。卡住了再展开。Below is the actual text of the lesson “变式三 · fetch 取数:loading、error 与竞态” — the same content as in the course, not a rewritten summary. Expand it when you stall.

展开《变式三 · fetch 取数:loading、error 与竞态》(6 段 · 约 18 分钟)Expand “变式三 · fetch 取数:loading、error 与竞态” (6 sections · ~18 min)

完整那一节(含练习、常见错误、迁移模式)在The full lesson (with exercises, common mistakes and transfer patterns) is at 变式三 · fetch 取数:loading、error 与竞态

§01

三态骨架The three-state skeleton

loading / error / data。顺序和优先级都有讲究。loading, error, data. Both the order and which one wins matter.

标准写法是三个 state 加三个提前返回:

顺序不能乱。先判 loading,再判 error, 最后才渲染数据。如果先判 !user, 第一次渲染时(还在加载)就会闪一下「没有数据」。

loading 初始值必须是 true写成 false 的话,首帧会先渲染「没有数据」再切成 Loading,界面闪一下。因为 effect 是在渲染之后才跑的。

换 id 时要把 user 清空。否则切到新用户的加载过程中,屏幕上还挂着上一个用户的资料 —— 看起来像「数据错了」。

The standard shape is three pieces of state and three early returns:

The order cannot be shuffled. Check loading, then error, and only then render the data. Check !user first and the very first render — still loading — flashes “no data”.

loading has to start at true. Start it at false and the first frame renders “no data” before switching to Loading, so the UI flickers. The effect runs after the render, that is why.

Clear user when the id changes. Otherwise the previous user’s profile sits on screen while the new one loads — and it looks like the data is simply wrong.

TSX三态与渲染优先级The three states and the order they are checked in已跑通Verified
1const [user, setUser] = useState<User | null>(null);
2const [loading, setLoading] = useState(true); // 初始就是 true
3const [error, setError] = useState<string | null>(null);
4
5// ...effect...
6
7if (loading) return <p data-testid="loading">Loading</p>;
8if (error) return <p data-testid="error">出错了:{error}</p>;
9if (!user) return <p data-testid="empty">没有数据</p>;
10return <article data-testid="user"></article>;
1const [user, setUser] = useState<User | null>(null);
2const [loading, setLoading] = useState(true); // true from the start
3const [error, setError] = useState<string | null>(null);
4
5// ...effect...
6
7if (loading) return <p data-testid="loading">Loading</p>;
8if (error) return <p data-testid="error">出错了:{error}</p>;
9if (!user) return <p data-testid="empty">没有数据</p>;
10return <article data-testid="user"></article>;
§02

fetch 的第一个坑:404 不会 rejectThe first trap in fetch: a 404 does not reject

这是所有 fetch 题的必考点。Every fetch question checks this one.

fetch 的 promise 只在网络层失败时 reject(断网、DNS 挂了、CORS 被拒)。 服务器返回 404 或 500 时,它是成功的—— 你成功地拿到了一个「失败响应」。

所以不检查 res.ok 的话,res.json() 会去解析错误页的内容, 然后你把它当用户数据渲染出来 —— 轻则显示 undefined,重则整页崩。

这一点和 axios 相反(axios 会对非 2xx 抛错), 所以从 axios 转过来的人特别容易漏。

A fetch promise only rejects when the network layer fails (offline, DNS down, CORS refused). When the server answers 404 or 500 the call succeeded — you successfully got a failure response.

So if you skip the res.ok check, res.json() goes off and parses the error page, and then you render that as user data — mild case, it shows undefined; bad case, the page crashes.

This is the opposite of axios (axios throws on any non-2xx), so people coming over from axios miss it especially often.

TSX必须自己检查 res.okYou have to check res.ok yourself已跑通Verified
1const res = await fetch(`/api/users/${userId}`);
2
3// fetch 只在网络层失败时 reject;404/500 是「成功拿到一个失败响应」
4if (!res.ok) throw new Error(`HTTP ${res.status}`);
5
6const data: User = await res.json();
1const res = await fetch(`/api/users/${userId}`);
2
3// fetch only rejects when the network layer fails; a 404 or 500 is a failure response you received successfully
4if (!res.ok) throw new Error(`HTTP ${res.status}`);
5
6const data: User = await res.json();
测试里专门有一条 treats a 404 as an error:mock 一个 { ok: false, status: 404 },断言界面显示 HTTP 404 而不是崩掉。One test exists for exactly this, treats a 404 as an error: it mocks { ok: false, status: 404 } and asserts that the screen shows HTTP 404 instead of crashing.
§03

真正的考点:竞态What is really being tested: the race between two requests

用户飞快切换 id,两个请求同时在飞,谁后回来谁说话 —— 而后回来的可能是旧的。The user switches id quickly, two requests are in flight, and whichever answers last wins. The one that answers last may be the older one.

场景:用户点了用户 1(这个请求很慢,200ms), 马上又点了用户 2(这个很快,10ms)。

没有防护的话,最终屏幕上显示的是用户 1—— 因为它最后才回来,把用户 2 的数据覆盖了。 URL 上是 2,界面上是 1。

解法是在清理函数里立一个「这次请求作废」的旗子:

ignore普通局部变量, 每次 effect 执行都有自己的一份。清理函数通过闭包改的是它那一次ignore。 所以旧请求回来时看到的是自己的 ignore === true, 于是什么都不做。

为什么不用 state 存这个旗子?因为它不参与渲染,而且每次 effect 需要独立的一份 —— state 是共享的,会互相干扰。

The scenario: the user clicks user 1 (a slow request, 200ms), then immediately clicks user 2 (a fast one, 10ms).

With no guard, the screen ends up showing user 1 — it came back last and overwrote user 2’s data. The URL says 2, the UI says 1.

The fix is to raise a “this request no longer counts” flag in the cleanup:

ignore is a plain local variable, and every run of the effect gets its own. Through the closure, a cleanup only changes its own ignore. So when the old request comes back it sees its own ignore === true and does nothing.

Why not keep the flag in state? Because it takes no part in rendering, and every run of the effect needs a separate one — state is shared, so the runs would interfere with each other.

Text竞态的时间线The timeline of the race示意Illustrative
1t=0 用户点了 1 -> effect#1 发请求(要 200ms)
2t=20 用户点了 2 -> effect#1 的清理函数跑:ignore#1 = true
3 -> effect#2 发请求(要 10ms)
4t=30 用户 2 的响应回来 -> ignore#2 是 false -> setUser(用户2) ✓
5t=200 用户 1 的响应回来 -> ignore#1 是 true -> 直接丢掉 ✓
6
7# 没有 ignore 的话,t=200 那一刻会 setUser(用户1),
8# 界面变回用户 1,而 URL 上还是 2。
1t=0 user clicks 1 -> effect#1 sends a request (takes 200ms)
2t=20 user clicks 2 -> effect#1 cleanup function runs: ignore#1 = true
3 -> effect#2 sends a request (takes 10ms)
4t=30 response for user 2 arrives -> ignore#2 is false -> setUser(user 2) ✓
5t=200 response for user 1 arrives -> ignore#1 is true -> thrown away ✓
6
7# Without ignore, at t=200 it would call setUser(user 1),
8# the screen goes back to user 1 while the URL still says 2.
TSX竞态的解法How the race is settled已跑通Verified
1useEffect(() => {
2 let ignore = false; // 每次 effect 自己的一份
3
4 (async () => {
5 const res = await fetch(`/api/users/${userId}`);
6 if (!res.ok) throw new Error(`HTTP ${res.status}`);
7 const data = await res.json();
8 if (!ignore) setUser(data); // ← 作废了就什么都不做
9 })();
10
11 return () => { ignore = true; }; // 清理函数只做这一件事
12}, [userId]);
1useEffect(() => {
2 let ignore = false; // one of these per run of the effect
3
4 (async () => {
5 const res = await fetch(`/api/users/${userId}`);
6 if (!res.ok) throw new Error(`HTTP ${res.status}`);
7 const data = await res.json();
8 if (!ignore) setUser(data); // ← if this run no longer counts, do nothing
9 })();
10
11 return () => { ignore = true; }; // the cleanup does only this
12}, [userId]);
§04

AbortController 和 ignore 解决的不是同一件事AbortController and the ignore flag do not solve the same problem

两个都要,各管一头。You want both. Each one covers a different end.

解决什么不解决什么
ignore 标志旧响应不许写 state(竞态、 以及卸载后 setState)网络请求本身还在跑,流量照走
AbortController真的把在途请求掐掉,省流量和服务器资源不是所有环境都尊重 signal(比如被 mock 掉的 fetch、 某些 polyfill),所以不能只靠它

所以生产写法是两个一起用。 另外 abort() 会让 await fetch 抛一个AbortError —— 那是我们自己干的,不能当成错误展示给用户,要在 catch 里过滤掉。

测试里有一条 aborts the in-flight request on unmount: mock 的 fetch 把收到的 signal 存下来, 卸载后断言 signal.aborted === true

What it solvesWhat it does not
The ignore flagAn old response may not write state (races, plus setState after unmount)The request itself keeps running and still burns bandwidth
AbortControllerActually cuts off the in-flight request, saving bandwidth and server workNot every environment respects the signal (a mocked fetch, some polyfills), so it cannot be your only guard

So production code uses both. One more thing: abort() makes await fetch throw an AbortError — we did that to ourselves, so it must not be shown to the user as an error. Filter it out in the catch.

One test covers this, aborts the in-flight request on unmount: the mocked fetch stores the signal it received, and after unmount the test asserts signal.aborted === true.

TSX两者配合The two working together已跑通Verified
1const controller = new AbortController();
2
3const res = await fetch(url, { signal: controller.signal });
4// ...
5} catch (e) {
6 const err = e as Error;
7 // 主动取消不是错误,别展示给用户
8 if (!ignore && err.name !== "AbortError") setError(err.message);
9}
10
11return () => {
12 ignore = true;
13 controller.abort();
14};
1const controller = new AbortController();
2
3const res = await fetch(url, { signal: controller.signal });
4// ...
5} catch (e) {
6 const err = e as Error;
7 // Cancelling on purpose is not an error, so do not show it to the user
8 if (!ignore && err.name !== "AbortError") setError(err.message);
9}
10
11return () => {
12 ignore = true;
13 controller.abort();
14};
§05

完整答案The complete answer

6 个测试全过,包含竞态和 abort 两条。All 6 tests pass, including one for the race and one for abort.

注意 async 逻辑包在一个立即执行的 async 箭头函数里, 而不是把 effect 本身写成 async —— 因为 effect 的返回值必须是清理函数,async 函数返回的是 Promise,React 会警告。

finally 里也要判 ignore: 否则旧请求回来时会把新请求的 loading 提前关掉, 出现「转圈消失但数据还没到」的空窗。

Notice the async logic sits inside an immediately invoked async arrow function rather than making the effect itself async — the effect’s return value has to be the cleanup, and an async function returns a Promise, which makes React warn.

finally has to check ignore too: otherwise an old request coming back turns off the new request’s loading too early, and you get a gap where the spinner is gone but the data has not arrived.

TSXsrc/components/UserCard/index.tsx(实测 6/6 通过)src/components/UserCard/index.tsx (6 of 6 pass here)已跑通Verified
1import React, { useEffect, useState } from "react";
2import type { User } from "../../types/User";
3
4const UserCard: React.FC<{ userId: number }> = ({ userId }) => {
5 const [user, setUser] = useState<User | null>(null);
6 const [loading, setLoading] = useState(true);
7 const [error, setError] = useState<string | null>(null);
8
9 useEffect(() => {
10 // ignore 是「本次请求还算不算数」的开关。
11 // userId 一变,旧 effect 的清理函数先把它置 true,
12 // 于是旧请求即使晚回来也不会覆盖新数据 —— 这就是竞态的解法。
13 let ignore = false;
14 const controller = new AbortController();
15
16 setLoading(true);
17 setError(null);
18 setUser(null);
19
20 (async () => {
21 try {
22 const res = await fetch(`/api/users/${userId}`, { signal: controller.signal });
23
24 // fetch 只在网络层失败时 reject。
25 // 404 / 500 是「成功拿到一个失败响应」,必须自己检查 res.ok,
26 // 否则会把错误页当数据用。
27 if (!res.ok) throw new Error(`HTTP ${res.status}`);
28
29 const data: User = await res.json();
30 if (!ignore) setUser(data);
31 } catch (e) {
32 // 主动取消不是错误,别展示给用户
33 const err = e as Error;
34 if (!ignore && err.name !== "AbortError") setError(err.message);
35 } finally {
36 if (!ignore) setLoading(false);
37 }
38 })();
39
40 return () => {
41 ignore = true;
42 controller.abort(); // 顺手掐掉在途请求,省流量
43 };
44 }, [userId]);
45
46 if (loading) return <p data-testid="loading">Loading</p>;
47 if (error) return <p data-testid="error">出错了:{error}</p>;
48 if (!user) return <p data-testid="empty">没有数据</p>;
49
50 return (
51 <article data-testid="user">
52 <h2 data-testid="user-name">{user.name}</h2>
53 <p data-testid="user-email">{user.email}</p>
54 </article>
55 );
56};
57
58export default UserCard;
1import React, { useEffect, useState } from "react";
2import type { User } from "../../types/User";
3
4const UserCard: React.FC<{ userId: number }> = ({ userId }) => {
5 const [user, setUser] = useState<User | null>(null);
6 const [loading, setLoading] = useState(true);
7 const [error, setError] = useState<string | null>(null);
8
9 useEffect(() => {
10 // ignore is the switch for whether this request still counts.
11 // When userId changes, the cleanup of the old effect sets it to true first,
12 // so a late old response cannot overwrite new data. That settles the race.
13 let ignore = false;
14 const controller = new AbortController();
15
16 setLoading(true);
17 setError(null);
18 setUser(null);
19
20 (async () => {
21 try {
22 const res = await fetch(`/api/users/${userId}`, { signal: controller.signal });
23
24 // fetch only rejects when the network layer fails.
25 // A 404 or 500 is a failure response received successfully, so check
26 // res.ok yourself or you will use the error page as data.
27 if (!res.ok) throw new Error(`HTTP ${res.status}`);
28
29 const data: User = await res.json();
30 if (!ignore) setUser(data);
31 } catch (e) {
32 // Cancelling on purpose is not an error, so do not show it to the user
33 const err = e as Error;
34 if (!ignore && err.name !== "AbortError") setError(err.message);
35 } finally {
36 if (!ignore) setLoading(false);
37 }
38 })();
39
40 return () => {
41 ignore = true;
42 controller.abort(); // also cut off the request in flight and save bandwidth
43 };
44 }, [userId]);
45
46 if (loading) return <p data-testid="loading">Loading</p>;
47 if (error) return <p data-testid="error">出错了:{error}</p>;
48 if (!user) return <p data-testid="empty">没有数据</p>;
49
50 return (
51 <article data-testid="user">
52 <h2 data-testid="user-name">{user.name}</h2>
53 <p data-testid="user-email">{user.email}</p>
54 </article>
55 );
56};
57
58export default UserCard;
§06

怎么验证How to check it

竞态这种「偶尔才出现」的 bug,怎么稳定地测出来?答案是自己控制谁先回来。How do you reliably test a bug that only appears now and then? You decide yourself which request answers first.

关键手法是 deferred promise: 造一个 promise,把它的 resolve 抓在手里, 想让哪个请求什么时候回来,就手动调它。 这样「慢的先发、快的后发、慢的最后才回来」这个顺序 是确定的,不靠 setTimeout 赌时间。

vi.stubGlobal("fetch", ...) 把全局fetch 换成假的,按 URL 决定返回哪个 promise。 注意假的响应对象要自己带上 ok /status / json()—— 因为组件用的就是这三个。

最后那条 aborts the in-flight request on unmount用了个小技巧:假 fetch 返回一个永不 settle 的 promisenew Promise(() => )), 把收到的 signal 存下来,卸载后断言signal.aborted === true

The key move is a deferred promise: build a promise and keep its resolve in your hand, then call it whenever you want that request to come back. That makes the order “slow one sent first, fast one second, slow one resolves last” deterministic, instead of betting on setTimeout.

vi.stubGlobal("fetch", ...) replaces the global fetch with a fake one that picks a promise by URL. The fake response object has to carry ok / status / json() itself — those three are exactly what the component uses.

The last test, aborts the in-flight request on unmount, uses a small trick: the fake fetch returns a promise that never settles (new Promise(() => )), stores the signal it received, and after unmount asserts signal.aborted === true.

Terminal验证命令The command that verifies it已跑通Verified
1npx vitest run src/UserCard.test.tsx # 6 passed
TSXsrc/UserCard.test.tsx(DrillLab 自出,本机跑过)src/UserCard.test.tsx (written for DrillLab, run here)已跑通Verified
1import { render, screen, waitFor } from "@testing-library/react";
2import { afterEach, expect, test, vi } from "vitest";
3import UserCard from "./components/UserCard";
4
5type Deferred<T> = { promise: Promise<T>; resolve: (v: T) => void; reject: (e: unknown) => void };
6function deferred<T>(): Deferred<T> {
7 let resolve!: (v: T) => void;
8 let reject!: (e: unknown) => void;
9 const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
10 return { promise, resolve, reject };
11}
12
13const okRes = (body: unknown) => ({ ok: true, status: 200, json: async () => body });
14
15afterEach(() => { vi.unstubAllGlobals(); });
16
17test("shows loading first, then the data", async () => {
18 const d = deferred<unknown>();
19 vi.stubGlobal("fetch", vi.fn(() => d.promise));
20
21 render(<UserCard userId={1} />);
22 expect(screen.getByTestId("loading")).toBeInTheDocument();
23
24 d.resolve(okRes({ id: 1, name: "张三", email: "z@example.com" }));
25 expect(await screen.findByTestId("user-name")).toHaveTextContent("张三");
26 expect(screen.getByTestId("user-email")).toHaveTextContent("z@example.com");
27 expect(screen.queryByTestId("loading")).toBeNull();
28});
29
30test("treats a 404 as an error(fetch 不会因为 404 而 reject)", async () => {
31 vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 404, json: async () => ({}) })));
32
33 render(<UserCard userId={9} />);
34 expect(await screen.findByTestId("error")).toHaveTextContent("HTTP 404");
35});
36
37test("shows an error when the network fails", async () => {
38 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("Failed to fetch"); }));
39
40 render(<UserCard userId={1} />);
41 expect(await screen.findByTestId("error")).toHaveTextContent("Failed to fetch");
42});
43
44test("refetches when userId changes", async () => {
45 const spy = vi.fn(async (url: string) =>
46 okRes({ id: Number(url.split("/").pop()), name: `用户${url.split("/").pop()}`, email: "x@y.z" }),
47 );
48 vi.stubGlobal("fetch", spy);
49
50 const { rerender } = render(<UserCard userId={1} />);
51 expect(await screen.findByTestId("user-name")).toHaveTextContent("用户1");
52
53 rerender(<UserCard userId={2} />);
54 expect(await screen.findByTestId("user-name")).toHaveTextContent("用户2");
55 expect(spy).toHaveBeenCalledTimes(2);
56});
57
58test("a slow stale response must not overwrite the newer one(竞态)", async () => {
59 const slow = deferred<unknown>(); // userId 1,很慢
60 const fast = deferred<unknown>(); // userId 2,很快
61 vi.stubGlobal(
62 "fetch",
63 vi.fn((url: string) => (url.endsWith("/1") ? slow.promise : fast.promise)),
64 );
65
66 const { rerender } = render(<UserCard userId={1} />);
67 rerender(<UserCard userId={2} />); // 用户飞快切到了 2
68
69 fast.resolve(okRes({ id: 2, name: "用户2", email: "b@x.z" }));
70 expect(await screen.findByTestId("user-name")).toHaveTextContent("用户2");
71
72 // 现在旧请求才回来 —— 它必须被忽略
73 slow.resolve(okRes({ id: 1, name: "用户1", email: "a@x.z" }));
74 await waitFor(() => expect(screen.getByTestId("user-name")).toHaveTextContent("用户2"));
75 expect(screen.getByTestId("user-name")).not.toHaveTextContent("用户1");
76});
77
78test("aborts the in-flight request on unmount", async () => {
79 const signals: AbortSignal[] = [];
80 vi.stubGlobal("fetch", vi.fn((_url: string, init: RequestInit) => {
81 signals.push(init.signal as AbortSignal);
82 return new Promise(() => {}); // 永不 settle
83 }));
84
85 const { unmount } = render(<UserCard userId={1} />);
86 expect(signals[0].aborted).toBe(false);
87 unmount();
88 expect(signals[0].aborted).toBe(true);
89});
1import { render, screen, waitFor } from "@testing-library/react";
2import { afterEach, expect, test, vi } from "vitest";
3import UserCard from "./components/UserCard";
4
5type Deferred<T> = { promise: Promise<T>; resolve: (v: T) => void; reject: (e: unknown) => void };
6function deferred<T>(): Deferred<T> {
7 let resolve!: (v: T) => void;
8 let reject!: (e: unknown) => void;
9 const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
10 return { promise, resolve, reject };
11}
12
13const okRes = (body: unknown) => ({ ok: true, status: 200, json: async () => body });
14
15afterEach(() => { vi.unstubAllGlobals(); });
16
17test("shows loading first, then the data", async () => {
18 const d = deferred<unknown>();
19 vi.stubGlobal("fetch", vi.fn(() => d.promise));
20
21 render(<UserCard userId={1} />);
22 expect(screen.getByTestId("loading")).toBeInTheDocument();
23
24 d.resolve(okRes({ id: 1, name: "张三", email: "z@example.com" }));
25 expect(await screen.findByTestId("user-name")).toHaveTextContent("张三");
26 expect(screen.getByTestId("user-email")).toHaveTextContent("z@example.com");
27 expect(screen.queryByTestId("loading")).toBeNull();
28});
29
30test("treats a 404 as an error(fetch 不会因为 404 而 reject)", async () => {
31 vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 404, json: async () => ({}) })));
32
33 render(<UserCard userId={9} />);
34 expect(await screen.findByTestId("error")).toHaveTextContent("HTTP 404");
35});
36
37test("shows an error when the network fails", async () => {
38 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("Failed to fetch"); }));
39
40 render(<UserCard userId={1} />);
41 expect(await screen.findByTestId("error")).toHaveTextContent("Failed to fetch");
42});
43
44test("refetches when userId changes", async () => {
45 const spy = vi.fn(async (url: string) =>
46 okRes({ id: Number(url.split("/").pop()), name: `用户${url.split("/").pop()}`, email: "x@y.z" }),
47 );
48 vi.stubGlobal("fetch", spy);
49
50 const { rerender } = render(<UserCard userId={1} />);
51 expect(await screen.findByTestId("user-name")).toHaveTextContent("用户1");
52
53 rerender(<UserCard userId={2} />);
54 expect(await screen.findByTestId("user-name")).toHaveTextContent("用户2");
55 expect(spy).toHaveBeenCalledTimes(2);
56});
57
58test("a slow stale response must not overwrite the newer one(竞态)", async () => {
59 const slow = deferred<unknown>(); // userId 1, slow
60 const fast = deferred<unknown>(); // userId 2, fast
61 vi.stubGlobal(
62 "fetch",
63 vi.fn((url: string) => (url.endsWith("/1") ? slow.promise : fast.promise)),
64 );
65
66 const { rerender } = render(<UserCard userId={1} />);
67 rerender(<UserCard userId={2} />); // the user switched to 2 at once
68
69 fast.resolve(okRes({ id: 2, name: "用户2", email: "b@x.z" }));
70 expect(await screen.findByTestId("user-name")).toHaveTextContent("用户2");
71
72 // only now does the old request come back, and it has to be ignored
73 slow.resolve(okRes({ id: 1, name: "用户1", email: "a@x.z" }));
74 await waitFor(() => expect(screen.getByTestId("user-name")).toHaveTextContent("用户2"));
75 expect(screen.getByTestId("user-name")).not.toHaveTextContent("用户1");
76});
77
78test("aborts the in-flight request on unmount", async () => {
79 const signals: AbortSignal[] = [];
80 vi.stubGlobal("fetch", vi.fn((_url: string, init: RequestInit) => {
81 signals.push(init.signal as AbortSignal);
82 return new Promise(() => {}); // never settles
83 }));
84
85 const { unmount } = render(<UserCard userId={1} />);
86 expect(signals[0].aborted).toBe(false);
87 unmount();
88 expect(signals[0].aborted).toBe(true);
89});
§04

参考答案Reference solution

提示是一级一级放的。四级看完还写不出来,再开答案门。The hints come one level at a time. If all four leave you stuck, open the answer.

提示Hints共 4 级,已看 0 级4 levels, 0 opened
先自己想两分钟。想不出来再点右上角 —— 提示是一级一级放的,不会一次给完。Think for two minutes first. Then use the button above — hints come one level at a time, never all at once.

这份答案在本机真跑过测试。但先确认你自己动手写过一遍 —— 读懂答案和写出答案是两种能力,考场上考的是后一种。This answer really was run here and its tests passed. But write it yourself first — reading an answer and producing one are two different skills, and the exam tests the second.