修复前端异步读取卸载竞态
未登录画布不再触发受保护的钱包刷新 充值中心读取在换代与卸载时中止并失效 补充卸载取消回归测试与共享排障经验
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user