From 6d8c7ae496a5e77ccdf475af633913382af118e2 Mon Sep 17 00:00:00 2001 From: menghao Date: Tue, 4 Aug 2026 10:38:28 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=89=8D=E7=AB=AF=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E8=AF=BB=E5=8F=96=E5=8D=B8=E8=BD=BD=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 未登录画布不再触发受保护的钱包刷新 充值中心读取在换代与卸载时中止并失效 补充卸载取消回归测试与共享排障经验 --- docs/project-memory/shared-memory/pitfalls.md | 7 ++ .../image-editor/ImageCanvasEditorView.tsx | 3 + ...sePlatformProfileCenterController.test.tsx | 82 +++++++++++++++++++ .../usePlatformProfileCenterController.ts | 30 ++++++- 4 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 src/components/platform-entry/usePlatformProfileCenterController.test.tsx diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 35d24bd12..85a6cb48e 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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 已中止;同时复跑触发钱包刷新回调的画布生成集成测试和完整前端测试,不能以单文件偶然快速收束代替全量验证。 diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index f608fe9c1..4e6d23d72 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -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]); diff --git a/src/components/platform-entry/usePlatformProfileCenterController.test.tsx b/src/components/platform-entry/usePlatformProfileCenterController.test.tsx new file mode 100644 index 000000000..c57737032 --- /dev/null +++ b/src/components/platform-entry/usePlatformProfileCenterController.test.tsx @@ -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((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; + }); +}); diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index 94c8d2f71..a3a0c966e 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -354,6 +354,16 @@ export function usePlatformProfileCenterController({ const pendingWechatRechargeOrderIdRef = useRef(null); const confirmingWechatRechargeOrderIdRef = useRef(null); const rechargeCenterReadRevisionRef = useRef(0); + const rechargeCenterReadAbortControllerRef = + useRef(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); }