diff --git a/apps/web/src/app/__tests__/fallback-poll.test.ts b/apps/web/src/app/__tests__/fallback-poll.test.ts new file mode 100644 index 0000000..e95128c --- /dev/null +++ b/apps/web/src/app/__tests__/fallback-poll.test.ts @@ -0,0 +1,135 @@ +/** + * SSE 的兜底轮询 —— **推送丢了,画面最多旧 10 秒**,不必等人按刷新。 + * + * 这里钉四条:①每个周期把这一屏**在用的**查询重取一遍(挂着观察者的才算在用; + * 早已离开的屏留在缓存里的键不碰);②`stop()` 之后定时器真的停了 —— 离开界面不许继续打接口; + * ③页签藏起来时不打,回到前台先补一趟;④正在飞的请求不被兜底那一下取消重发(在途去重)。 + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryObserver } from '@tanstack/react-query'; +import { startFallbackPolling } from '../fallback-poll.ts'; + +type Visibility = { + hidden: boolean; + fire(): void; + addEventListener(type: string, handler: () => void): void; + removeEventListener(type: string, handler: () => void): void; + readonly listeners: number; +}; + +/** 假的 document:只有 hidden 与 visibilitychange 这两样,测试自己拨 */ +function fakeDoc(): Visibility { + const handlers = new Set<() => void>(); + return { + hidden: false, + fire: () => handlers.forEach((h) => h()), + addEventListener: (_type, h) => void handlers.add(h), + removeEventListener: (_type, h) => void handlers.delete(h), + get listeners() { + return handlers.size; + }, + }; +} + +/** 一把「在用的」查询:挂一个观察者,像组件 mount 了一样;回报它被取了几次(`delayOf` 按第几次给时延) */ +function mounted(qc: QueryClient, key: readonly unknown[], delayOf: (call: number) => number = () => 0) { + let calls = 0; + const observer = new QueryObserver(qc, { + queryKey: key, + queryFn: async () => { + calls += 1; + const delayMs = delayOf(calls); + if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs)); + return { n: calls }; + }, + }); + const unsubscribe = observer.subscribe(() => undefined); + return { calls: () => calls, unsubscribe }; +} + +const flush = async (): Promise => { + await vi.advanceTimersByTimeAsync(0); +}; + +describe('startFallbackPolling', () => { + let qc: QueryClient; + beforeEach(() => { + vi.useFakeTimers(); + qc = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 5_000 } } }); + }); + afterEach(() => { + qc.clear(); + vi.useRealTimers(); + }); + + it('每 10 秒把 desk 下在用的查询各重取一次;别的前缀与没人看的键不碰', async () => { + const tasks = mounted(qc, ['desk', 'tasks']); + const detail = mounted(qc, ['desk', 'task', 'T1']); + const gov = mounted(qc, ['gov', 'tasks']); + // 早已离开的屏:缓存里有,没人看 + qc.setQueryData(['desk', 'task', 'T0'], { stale: true }); + await flush(); + expect([tasks.calls(), detail.calls(), gov.calls()]).toEqual([1, 1, 1]); // 首取 + + const stop = startFallbackPolling(qc, { intervalMs: 10_000, prefix: ['desk'], doc: fakeDoc() }); + await vi.advanceTimersByTimeAsync(10_000); + expect([tasks.calls(), detail.calls()]).toEqual([2, 2]); + await vi.advanceTimersByTimeAsync(10_000); + expect([tasks.calls(), detail.calls()]).toEqual([3, 3]); + expect(gov.calls()).toBe(1); + expect(qc.getQueryState(['desk', 'task', 'T0'])?.fetchStatus).toBe('idle'); + stop(); + }); + + it('stop() 之后定时器停了,监听也摘了 —— 离开界面不再打接口', async () => { + const tasks = mounted(qc, ['desk', 'tasks']); + const doc = fakeDoc(); + await flush(); + const stop = startFallbackPolling(qc, { intervalMs: 10_000, prefix: ['desk'], doc }); + expect(doc.listeners).toBe(1); + await vi.advanceTimersByTimeAsync(10_000); + expect(tasks.calls()).toBe(2); + + stop(); + expect(doc.listeners).toBe(0); + await vi.advanceTimersByTimeAsync(60_000); + expect(tasks.calls()).toBe(2); + }); + + it('页签藏起来时不打;回到前台先补一趟,周期照旧', async () => { + const tasks = mounted(qc, ['desk', 'tasks']); + const doc = fakeDoc(); + await flush(); + const stop = startFallbackPolling(qc, { intervalMs: 10_000, prefix: ['desk'], doc }); + + doc.hidden = true; + doc.fire(); + await vi.advanceTimersByTimeAsync(30_000); + expect(tasks.calls()).toBe(1); + + doc.hidden = false; + doc.fire(); + await flush(); + expect(tasks.calls()).toBe(2); // 回前台立刻补一趟 + await vi.advanceTimersByTimeAsync(10_000); + expect(tasks.calls()).toBe(3); + stop(); + }); + + it('上一趟还在飞,兜底这一下不取消它重发 —— 慢接口不会被兜底打成永远取不回', async () => { + /* + * 首取要先回来:TanStack 只对**已有数据**的查询做 cancelRefetch(首取飞着时谁都不掐), + * 所以「第二趟慢」才是有分辨力的局面 —— 第一版拿首取当慢趟,true/false 都过。 + */ + const slow = mounted(qc, ['desk', 'tasks'], (call) => (call === 1 ? 0 : 25_000)); + await flush(); + expect(slow.calls()).toBe(1); + const stop = startFallbackPolling(qc, { intervalMs: 10_000, prefix: ['desk'], doc: fakeDoc() }); + await vi.advanceTimersByTimeAsync(10_000); + expect(slow.calls()).toBe(2); // 第二趟起飞,要 25 秒 + await vi.advanceTimersByTimeAsync(10_000); + // 第二趟还没回来:这一个兜底周期不该把它取消再发(cancelRefetch:true 会让这里变成 3) + expect(slow.calls()).toBe(2); + stop(); + }); +}); diff --git a/apps/web/src/app/app-shell.tsx b/apps/web/src/app/app-shell.tsx index e0fb1ae..ff19ffa 100644 --- a/apps/web/src/app/app-shell.tsx +++ b/apps/web/src/app/app-shell.tsx @@ -6,7 +6,7 @@ * * **前端零过程状态**:数据全部来自服务端投影,刷新即从投影重建(§9.2)。 */ -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { Principal, TaskCard, TaskDetail, WorkOrderReply } from '@contracts'; import { @@ -28,6 +28,7 @@ import { call } from '@web-shared/typed-fetch.ts'; import { logout as kcLogout } from '@web-shared/auth.ts'; import { useChatWidth } from '@web-shared/useChatWidth.ts'; import { EmptyState } from '@web-shared/desk/module-kit.tsx'; +import { startFallbackPolling } from './fallback-poll.ts'; import { queryKeys } from './query.ts'; type ModuleKey = 'tasks' | 'approvals' | 'messages' | 'minutes' | 'report'; @@ -40,6 +41,9 @@ const NAV_MODULES: ReadonlyArray<{ key: ModuleKey; label: string; icon: string } { key: 'report', label: '汇报', icon: 'bar-chart' }, ]; +/** 兜底轮询周期 —— 用户定 10 秒:比这密就成了轮询,比这疏推送漏了人等得出来 */ +const FALLBACK_POLL_MS = 10_000; + export function AppShell() { const queryClient = useQueryClient(); const [module, setModule] = useState('tasks'); @@ -109,6 +113,19 @@ export function AppShell() { */ const detailPending = current !== '' && detail.isPending; + /* + * SSE 的兜底:任务界面开着的时候每 10 秒把 `desk` 下在用的查询(清单 / 这一单 / 角标) + * 各重取一次 —— 推送漏了画面最多旧 10 秒,不必等人按刷新(2026-08-28 用户定)。 + * 离开这一屏(切模块 / 壳层卸载)effect 清理即 `stop()`,定时器与可见性监听一起摘。 + */ + useEffect( + () => + module === 'tasks' + ? startFallbackPolling(queryClient, { intervalMs: FALLBACK_POLL_MS, prefix: queryKeys.desk }) + : undefined, + [module, queryClient], + ); + diff --git a/apps/web/src/app/fallback-poll.ts b/apps/web/src/app/fallback-poll.ts new file mode 100644 index 0000000..51e7186 --- /dev/null +++ b/apps/web/src/app/fallback-poll.ts @@ -0,0 +1,57 @@ +/** + * SSE 的兜底轮询 —— **推送丢了,画面最多旧 10 秒**(2026-08-28 用户定)。 + * + * 刷新的正路仍是订阅层(`subscribe.ts`:推送到了 → 按键失效 → 重取);这里只是兜底: + * 断线那几秒漏掉的推送、桥没接上的那一台、任何一处忘了广播的写路由 —— 从前都得靠人按刷新。 + * 编码规范 §9.2「组件禁止自行轮询」照旧算数:兜底只此一处、由壳层起停,组件不许各自再起定时器。 + * + * 三条约束: + * · 只重取**在用的**查询(`type: 'active'`,挂着观察者的才算)—— 早已离开的屏留在缓存里的键不碰; + * · **不取消在途请求**(`cancelRefetch: false`):慢接口正飞着,兜底那一下只是等它,不把它掐掉重发; + * · 页签藏起来不打(后台页签打接口是纯浪费),回到前台先补一趟。 + * + * 连接是 I/O,节奏是逻辑:`doc` 可注入,测试里拨 hidden 与 visibilitychange 就能钉死起停。 + * 返回 `stop()` —— 离开界面必须调它,定时器与监听一起摘。 + */ +import type { QueryClient } from '@tanstack/react-query'; + +export type VisibilitySource = { + readonly hidden: boolean; + addEventListener(type: 'visibilitychange', handler: () => void): void; + removeEventListener(type: 'visibilitychange', handler: () => void): void; +}; + +export type FallbackPollingOptions = { + /** 周期(毫秒);工作台任务界面定 10 秒 */ + readonly intervalMs: number; + /** 重取哪一支查询(按键前缀) */ + readonly prefix: readonly unknown[]; + /** 可见性来源;不传用 `document` */ + readonly doc?: VisibilitySource; +}; + +export function startFallbackPolling(queryClient: QueryClient, options: FallbackPollingOptions): () => void { + const doc: VisibilitySource = options.doc ?? document; + let stopped = false; + + const sweep = (): void => { + if (stopped || doc.hidden) return; + // 取不回来不算兜底的错:那一把查询自己会带着 error 状态,画面照实摆 + // `cancelRefetch` 是第二参(RefetchOptions),写进过滤器会被无视、实际走默认 true —— 测试逮过一次 + void queryClient + .refetchQueries({ queryKey: options.prefix, type: 'active' }, { cancelRefetch: false }) + .catch(() => undefined); + }; + const onVisibility = (): void => { + if (!doc.hidden) sweep(); + }; + + const timer = setInterval(sweep, options.intervalMs); + doc.addEventListener('visibilitychange', onVisibility); + + return () => { + stopped = true; + clearInterval(timer); + doc.removeEventListener('visibilitychange', onVisibility); + }; +} diff --git a/apps/web/src/app/query.ts b/apps/web/src/app/query.ts index 982d02e..e6f2a06 100644 --- a/apps/web/src/app/query.ts +++ b/apps/web/src/app/query.ts @@ -10,7 +10,7 @@ export { queryKeys }; export const queryClient = new QueryClient({ defaultOptions: { queries: { - // 缓存失效由 SSE 订阅层驱动,不靠组件轮询 + // 缓存失效由 SSE 订阅层驱动,不靠组件轮询;推送漏了有壳层的 10 秒兜底(fallback-poll.ts),组件不另起定时器 refetchOnWindowFocus: false, retry: 1, staleTime: 5_000, diff --git a/apps/web/src/app/subscribe.ts b/apps/web/src/app/subscribe.ts index fdf3bc8..42ffeeb 100644 --- a/apps/web/src/app/subscribe.ts +++ b/apps/web/src/app/subscribe.ts @@ -69,7 +69,7 @@ export function invalidateFor(queryClient: QueryClient, touch: Touch): void { * 负责人的工作台列表上;不连 `desk` 一起失效,那儿会留着一张点进去就 404 的卡片。 */ void queryClient.invalidateQueries({ queryKey: ['gov'] }); - void queryClient.invalidateQueries({ queryKey: ['desk'] }); + void queryClient.invalidateQueries({ queryKey: queryKeys.desk }); break; } } diff --git a/apps/web/src/shared/query-keys.ts b/apps/web/src/shared/query-keys.ts index 1a918c1..6991627 100644 --- a/apps/web/src/shared/query-keys.ts +++ b/apps/web/src/shared/query-keys.ts @@ -6,6 +6,8 @@ export const queryKeys = { /** 「我是谁、能进哪几个端」—— ADR-012 后身份来自 `GET /api/me`(Principal) */ me: ['me'] as const, + /** 工作台整支 —— 失效 / 兜底轮询按这个前缀扫,下面四把都在它名下 */ + desk: ['desk'] as const, tasks: ['desk', 'tasks'] as const, task: (id: string) => ['desk', 'task', id] as const, messages: ['desk', 'messages'] as const, diff --git a/docs/编码规范.md b/docs/编码规范.md index 0abaea8..48153b3 100644 --- a/docs/编码规范.md +++ b/docs/编码规范.md @@ -390,6 +390,7 @@ S-FEEL 子集(OS 03 定义域)的解析与求值,纯函数。被 `process`(边 - 视图区容器渲染活动推送的 HTML:**沙箱 iframe(`srcdoc` + `sandbox` 属性)或净化后注入,二选一由 `ui-kit` 的 `ViewContainer` 统一实现,feature 不得自行 `dangerouslySetInnerHTML`**;无脚本执行权(AM 07) - 卡片表单由 `@contracts/card.ts` schema 驱动渲染与校验,与后端回传校验同 schema - SSE 事件到达 → 按信封目标(任务/工作流/要素)**失效对应 TanStack Query 缓存**,由订阅层统一做;组件禁止自行轮询或手写缓存 +- 推送的兜底**只此一处**:`app/fallback-poll.ts`,工作台任务界面开着时每 10 秒把 `desk` 下在用的查询重取一遍(不取消在途、页签藏起不打、离开即停),由壳层起停 —— 组件仍不许各自起定时器(2026-08-28) - 路由状态即应用状态:当前区/页签/下钻栈可从 URL 完整还原(TanStack Router 类型化 search params);内存路由栈只是原型期形态,正式系统必须可深链 ---