修复前端异步读取卸载竞态
Project CI / Repository checks (pull_request) Successful in 55s
Project CI / Backend tests (pull_request) Successful in 4m13s
Project CI / Native shell tests (pull_request) Successful in 11m16s
Project CI / Frontend tests (pull_request) Successful in 2m29s

未登录画布不再触发受保护的钱包刷新
充值中心读取在换代与卸载时中止并失效
补充卸载取消回归测试与共享排障经验
This commit is contained in:
2026-08-04 10:38:28 +08:00
parent 80bcb4ba0e
commit 6d8c7ae496
4 changed files with 119 additions and 3 deletions
@@ -4083,3 +4083,10 @@
- 现象:可选 server 已成功连接,但返回超限 schema、重复 tool identity 或要求未支持 task-mode 时,整个 MCP catalog 和本轮 Agent planning 一起失败。
- 处理:连接、tools/list、工具归一化与聚合容量都使用同一 required / optional 边界。optional 将该 server 投影为 `connected=false + error + tool_count=0`,required 保持失败关闭;被包入 `action.input``$ref` 只重定位当前 document 根的 `#` / `#/...` JSON Pointer,命名 anchor、外部 URI 与带 `$id` 的 schema resource 内 fragment 不得改写。
## React 异步读取必须在组件卸载时中止并失效(2026-08-04)
- 现象:单个 Vitest 文件全部通过,全量 CI 却在 jsdom 环境销毁后出现 `ReferenceError: window is not defined`;栈指向请求 Promise 的 `finally` 中调用 React `setState`
- 原因:测试触发了与断言无关的账户读取,较快环境中请求会在用例结束前失败,较慢 CI 中请求延迟到组件和 jsdom 均已销毁后才收束。仅用 revision 丢弃旧请求而不在卸载时推进 revision,最后一个在途请求仍会被误认作当前请求。
- 处理:调用方在未认证时不得启动受保护的钱包刷新;可取消的读取要为每轮分配 `AbortController`,新读取先失效并中止旧读取,组件卸载时同时推进 revision、abort 当前请求并清空句柄。所有 `then / catch / finally` 在更新状态前都要检查 signal 与 revision。
- 验证:定向测试覆盖卸载后请求 signal 已中止;同时复跑触发钱包刷新回调的画布生成集成测试和完整前端测试,不能以单文件偶然快速收束代替全量验证。
@@ -544,6 +544,9 @@ export function ImageCanvasEditorView({
currentUser: authUi?.user ?? null,
});
const refreshEditorWalletState = useCallback(() => {
if (!authUiRef.current?.canAccessProtectedData || !authUiRef.current.user) {
return;
}
refreshEditorWalletBalance();
loadRechargeCenter();
}, [loadRechargeCenter, refreshEditorWalletBalance]);
@@ -0,0 +1,82 @@
/* @vitest-environment jsdom */
import { act, renderHook } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ProfileRechargeCenterResponse } from '../../../packages/shared/src/contracts/runtime';
import { usePlatformProfileCenterController } from './usePlatformProfileCenterController';
const getPlatformProfileRechargeCenterMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/platform-entry/platformProfileClient', async () => {
const actual = await vi.importActual<
typeof import('../../services/platform-entry/platformProfileClient')
>('../../services/platform-entry/platformProfileClient');
return {
...actual,
getPlatformProfileRechargeCenter: getPlatformProfileRechargeCenterMock,
};
});
const rechargeCenter: ProfileRechargeCenterResponse = {
walletBalance: 100,
mudPointBalance: {
totalPoints: 100,
permanentPoints: 100,
limitedPoints: 0,
limitedExpiresAt: null,
dailyFreePoints: 0,
dailyFreeResetPoints: 0,
dailyFreeResetsAt: null,
},
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
cycleStartedAt: null,
cycleResetsAt: null,
cycleGrantedPoints: 0,
cycleRemainingPoints: 0,
cyclePeriodDays: 30,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
};
describe('usePlatformProfileCenterController', () => {
afterEach(() => {
getPlatformProfileRechargeCenterMock.mockReset();
});
it('aborts an unfinished recharge center read when the consumer unmounts', async () => {
let resolveRechargeCenter!: (center: ProfileRechargeCenterResponse) => void;
const pendingRead = new Promise<ProfileRechargeCenterResponse>((resolve) => {
resolveRechargeCenter = resolve;
});
getPlatformProfileRechargeCenterMock.mockReturnValue(pendingRead);
const { result, unmount } = renderHook(() =>
usePlatformProfileCenterController({
activeTab: 'editor-canvas',
isAuthenticated: false,
showRechargeEntry: true,
requestLogin: vi.fn(),
currentUser: null,
}),
);
act(() => result.current.loadRechargeCenter());
const requestOptions = getPlatformProfileRechargeCenterMock.mock.calls[0]?.[0];
expect(requestOptions?.signal.aborted).toBe(false);
unmount();
expect(requestOptions?.signal.aborted).toBe(true);
resolveRechargeCenter(rechargeCenter);
await pendingRead;
});
});
@@ -354,6 +354,16 @@ export function usePlatformProfileCenterController({
const pendingWechatRechargeOrderIdRef = useRef<string | null>(null);
const confirmingWechatRechargeOrderIdRef = useRef<string | null>(null);
const rechargeCenterReadRevisionRef = useRef(0);
const rechargeCenterReadAbortControllerRef =
useRef<AbortController | null>(null);
useEffect(() => {
return () => {
rechargeCenterReadRevisionRef.current += 1;
rechargeCenterReadAbortControllerRef.current?.abort();
rechargeCenterReadAbortControllerRef.current = null;
};
}, []);
// 中文注释:支持带邀请码 query 的直达场景,登录成功后自动打开兑换面板并复用同一套输入状态。
useEffect(() => {
@@ -398,6 +408,8 @@ export function usePlatformProfileCenterController({
const applyRechargeCenter = useCallback(
(center: ProfileRechargeCenterResponse) => {
rechargeCenterReadRevisionRef.current += 1;
rechargeCenterReadAbortControllerRef.current?.abort();
rechargeCenterReadAbortControllerRef.current = null;
setIsLoadingRechargeCenter(false);
setRechargeError(null);
setRechargeCenter(center);
@@ -407,16 +419,25 @@ export function usePlatformProfileCenterController({
const loadRechargeCenter = useCallback(() => {
const revision = ++rechargeCenterReadRevisionRef.current;
rechargeCenterReadAbortControllerRef.current?.abort();
const abortController = new AbortController();
rechargeCenterReadAbortControllerRef.current = abortController;
setRechargeError(null);
setIsLoadingRechargeCenter(true);
void getPlatformProfileRechargeCenter()
void getPlatformProfileRechargeCenter({ signal: abortController.signal })
.then((center) => {
if (revision === rechargeCenterReadRevisionRef.current) {
if (
!abortController.signal.aborted &&
revision === rechargeCenterReadRevisionRef.current
) {
setRechargeCenter(center);
}
})
.catch((error: unknown) => {
if (revision !== rechargeCenterReadRevisionRef.current) {
if (
abortController.signal.aborted ||
revision !== rechargeCenterReadRevisionRef.current
) {
return;
}
setRechargeCenter(null);
@@ -425,6 +446,9 @@ export function usePlatformProfileCenterController({
);
})
.finally(() => {
if (rechargeCenterReadAbortControllerRef.current === abortController) {
rechargeCenterReadAbortControllerRef.current = null;
}
if (revision === rechargeCenterReadRevisionRef.current) {
setIsLoadingRechargeCenter(false);
}