From 2b5f08a17875242ec909a60b01a122fd7842cab7 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Thu, 23 Jul 2026 22:30:47 +0800 Subject: [PATCH 1/2] refactor(async): add scoped resource lifecycle Pre-commit hook ran. Total eslint: 10, total circular: 0 --- src/hooks/async/index.ts | 20 +- src/hooks/async/useAsyncData.ts | 237 -------------- src/hooks/async/useAsyncResource.test.ts | 296 ++++++++++++++++++ src/hooks/async/useAsyncResource.ts | 234 ++++++++++++++ .../async/useVisibilityPolledData.test.ts | 203 ++++++++++++ src/hooks/async/useVisibilityPolledData.ts | 68 ++++ .../core/__tests__/latestScopedTask.test.ts | 50 +++ .../__tests__/visibilityAwarePoll.test.ts | 112 +++++++ src/util/core/latestScopedTask.ts | 49 +++ src/util/core/visibilityAwarePoll.ts | 120 +++++++ 10 files changed, 1144 insertions(+), 245 deletions(-) delete mode 100644 src/hooks/async/useAsyncData.ts create mode 100644 src/hooks/async/useAsyncResource.test.ts create mode 100644 src/hooks/async/useAsyncResource.ts create mode 100644 src/hooks/async/useVisibilityPolledData.test.ts create mode 100644 src/hooks/async/useVisibilityPolledData.ts create mode 100644 src/util/core/__tests__/latestScopedTask.test.ts create mode 100644 src/util/core/__tests__/visibilityAwarePoll.test.ts create mode 100644 src/util/core/latestScopedTask.ts create mode 100644 src/util/core/visibilityAwarePoll.ts diff --git a/src/hooks/async/index.ts b/src/hooks/async/index.ts index a2a538279..b77495af7 100644 --- a/src/hooks/async/index.ts +++ b/src/hooks/async/index.ts @@ -1,9 +1,13 @@ -// Async data hooks export { - useAsyncData, - useAsyncAction, - type UseAsyncDataOptions, - type UseAsyncDataReturn, - type UseAsyncActionOptions, - type UseAsyncActionReturn, -} from "./useAsyncData"; + useAsyncResource, + type AsyncResourceFetchContext, + type AsyncResourceReloadOptions, + type AsyncResourceStatus, + type UseAsyncResourceOptions, + type UseAsyncResourceResult, +} from "./useAsyncResource"; +export { + useVisibilityPolledData, + type UseVisibilityPolledDataOptions, + type UseVisibilityPolledDataResult, +} from "./useVisibilityPolledData"; diff --git a/src/hooks/async/useAsyncData.ts b/src/hooks/async/useAsyncData.ts deleted file mode 100644 index 1d4ae0221..000000000 --- a/src/hooks/async/useAsyncData.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * useAsyncData Hook - * - * Generic hook for async data fetching with loading/error state management. - * Consolidates the common pattern found across 60+ hooks in the codebase. - * - * Features: - * - Unified loading/error/data state management - * - Auto-load on mount with dependency tracking - * - Success/error callbacks - * - Manual refresh capability - * - Type-safe with generics - * - * @example - * const { data, loading, error, refresh } = useAsyncData({ - * fetcher: () => api.fetchItems(), - * initialData: [], - * errorPrefix: "Failed to load items", - * }); - */ -import { - type Dispatch, - type SetStateAction, - useCallback, - useEffect, - useState, -} from "react"; - -import { useMounted } from "@src/hooks/lifecycle/useMounted"; - -// ============================================ -// Type Definitions -// ============================================ - -export interface UseAsyncDataOptions { - /** Async function to fetch data */ - fetcher: () => Promise; - /** Auto-load on mount (default: true) */ - autoLoad?: boolean; - /** Dependencies that trigger refetch when changed */ - deps?: unknown[]; - /** Success callback */ - onSuccess?: (data: T) => void; - /** Error callback */ - onError?: (error: Error) => void; - /** Initial data value */ - initialData?: T; - /** Error message prefix for generic errors */ - errorPrefix?: string; - /** Skip fetch if condition is false */ - enabled?: boolean; -} - -export interface UseAsyncDataReturn { - /** The fetched data */ - data: T; - /** Loading state */ - loading: boolean; - /** Error message (null if no error) */ - error: string | null; - /** Manually trigger a refresh */ - refresh: () => Promise; - /** Directly update the data state */ - setData: Dispatch>; - /** Clear the error state */ - clearError: () => void; -} - -// ============================================ -// Hook Implementation -// ============================================ - -export function useAsyncData( - options: UseAsyncDataOptions -): UseAsyncDataReturn { - const { - fetcher, - autoLoad = true, - deps = [], - onSuccess, - onError, - initialData, - errorPrefix = "Failed to load data", - enabled = true, - } = options; - - // State - const [data, setData] = useState(initialData as T); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const mountedRef = useMounted(); - - // Refresh function - const refresh = useCallback(async () => { - if (!enabled) { - return; - } - - setLoading(true); - setError(null); - - try { - const result = await fetcher(); - - if (mountedRef.current) { - setData(result); - onSuccess?.(result); - } - } catch (err) { - if (mountedRef.current) { - const message = - err instanceof Error ? err.message : `${errorPrefix}: ${String(err)}`; - setError(message); - onError?.(err instanceof Error ? err : new Error(message)); - } - } finally { - if (mountedRef.current) { - setLoading(false); - } - } - }, [fetcher, enabled, errorPrefix, onSuccess, onError, mountedRef]); - - // Clear error helper - const clearError = useCallback(() => { - setError(null); - }, []); - - // Auto-load on mount and when deps change - useEffect(() => { - if (autoLoad && enabled) { - refresh(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autoLoad, enabled, ...deps]); - - return { - data, - loading, - error, - refresh, - setData, - clearError, - }; -} - -// ============================================ -// Utility: useAsyncAction (for mutations) -// ============================================ - -export interface UseAsyncActionOptions { - /** Success callback */ - onSuccess?: () => void; - /** Error callback */ - onError?: (error: Error) => void; - /** Error message prefix */ - errorPrefix?: string; -} - -export interface UseAsyncActionReturn { - /** Execute the action */ - execute: (...args: TArgs) => Promise; - /** Loading state */ - loading: boolean; - /** Error message */ - error: string | null; - /** Clear error */ - clearError: () => void; -} - -/** - * Hook for async actions/mutations (create, update, delete operations) - * - * @example - * const { execute: createItem, loading } = useAsyncAction( - * async (name: string) => { - * return await api.createItem({ name }); - * }, - * { onSuccess: refresh } - * ); - */ -export function useAsyncAction( - action: (...args: TArgs) => Promise, - options: UseAsyncActionOptions = {} -): UseAsyncActionReturn { - const { onSuccess, onError, errorPrefix = "Action failed" } = options; - - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const mountedRef = useMounted(); - - const execute = useCallback( - async (...args: TArgs): Promise => { - setLoading(true); - setError(null); - - try { - const result = await action(...args); - - if (mountedRef.current) { - onSuccess?.(); - } - - return result; - } catch (err) { - if (mountedRef.current) { - const message = - err instanceof Error - ? err.message - : `${errorPrefix}: ${String(err)}`; - setError(message); - onError?.(err instanceof Error ? err : new Error(message)); - } - return null; - } finally { - if (mountedRef.current) { - setLoading(false); - } - } - }, - [action, errorPrefix, onSuccess, onError, mountedRef] - ); - - const clearError = useCallback(() => { - setError(null); - }, []); - - return { - execute, - loading, - error, - clearError, - }; -} - -export default useAsyncData; diff --git a/src/hooks/async/useAsyncResource.test.ts b/src/hooks/async/useAsyncResource.test.ts new file mode 100644 index 000000000..fc3a98fff --- /dev/null +++ b/src/hooks/async/useAsyncResource.test.ts @@ -0,0 +1,296 @@ +// @vitest-environment jsdom +import { act, createElement, useEffect } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { + type UseAsyncResourceResult, + useAsyncResource, +} from "./useAsyncResource"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + }); +} + +describe("useAsyncResource", () => { + let container: HTMLDivElement; + let root: Root; + let current: UseAsyncResourceResult; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + function Harness({ + autoLoad = true, + enabled = true, + fetcher, + initialData = "", + initialStatus = "idle", + scopeKey, + }: { + autoLoad?: boolean; + enabled?: boolean; + fetcher: Parameters>[0]["fetcher"]; + initialData?: string; + initialStatus?: "idle" | "ready"; + scopeKey: string | null; + }) { + const result = useAsyncResource({ + autoLoad, + enabled, + fetcher, + initialData, + initialStatus, + scopeKey, + }); + useEffect(() => { + current = result; + }, [result]); + return createElement("div", { + "data-error": result.error ?? "", + "data-loading": String(result.loading), + "data-refreshing": String(result.refreshing), + "data-status": result.status, + "data-value": result.data, + }); + } + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("loads and exposes one cohesive resource state", async () => { + const request = deferred(); + const fetcher = vi.fn().mockReturnValue(request.promise); + act(() => root.render(createElement(Harness, { fetcher, scopeKey: "a" }))); + + expect(container.firstElementChild?.getAttribute("data-status")).toBe( + "loading" + ); + request.resolve("loaded"); + await flush(); + + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "loaded" + ); + expect(container.firstElementChild?.getAttribute("data-status")).toBe( + "ready" + ); + }); + + it("drops a late response and hides old data after switching scope", async () => { + const first = deferred(); + const second = deferred(); + const fetcher = vi + .fn<(scopeKey: string) => Promise>() + .mockImplementation((scope) => + scope === "a" ? first.promise : second.promise + ); + + act(() => root.render(createElement(Harness, { fetcher, scopeKey: "a" }))); + act(() => root.render(createElement(Harness, { fetcher, scopeKey: "b" }))); + expect(container.firstElementChild?.getAttribute("data-value")).toBe(""); + + second.resolve("new"); + await flush(); + first.resolve("old"); + await flush(); + + expect(container.firstElementChild?.getAttribute("data-value")).toBe("new"); + }); + + it("starts a new generation for manual refresh and preserves visible data", async () => { + const stale = deferred(); + const fresh = deferred(); + const fetcher = vi + .fn<(scopeKey: string) => Promise>() + .mockReturnValueOnce(stale.promise) + .mockReturnValueOnce(fresh.promise); + + act(() => root.render(createElement(Harness, { fetcher, scopeKey: "a" }))); + stale.resolve("initial"); + await flush(); + + let refresh!: Promise; + act(() => { + refresh = current.refresh(); + }); + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "initial" + ); + expect(container.firstElementChild?.getAttribute("data-refreshing")).toBe( + "true" + ); + + fresh.resolve("fresh"); + await act(async () => refresh); + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "fresh" + ); + }); + + it("prevents an active initial load from overwriting a manual refresh", async () => { + const stale = deferred(); + const fresh = deferred(); + const fetcher = vi + .fn<(scopeKey: string) => Promise>() + .mockReturnValueOnce(stale.promise) + .mockReturnValueOnce(fresh.promise); + + act(() => root.render(createElement(Harness, { fetcher, scopeKey: "a" }))); + let refresh!: Promise; + act(() => { + refresh = current.refresh(); + }); + + fresh.resolve("fresh"); + await act(async () => refresh); + stale.resolve("stale"); + await flush(); + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "fresh" + ); + }); + + it("publishes cache data before the current live request settles", async () => { + const live = deferred(); + const fetcher = vi.fn( + async (_scopeKey: string, context: { publish(data: string): void }) => { + context.publish("cached"); + return live.promise; + } + ); + + act(() => root.render(createElement(Harness, { fetcher, scopeKey: "a" }))); + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "cached" + ); + expect(container.firstElementChild?.getAttribute("data-status")).toBe( + "ready" + ); + + live.resolve("live"); + await flush(); + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "live" + ); + }); + + it("can expose seeded cache data without starting a request", () => { + const fetcher = vi.fn().mockResolvedValue("unused"); + act(() => + root.render( + createElement(Harness, { + autoLoad: false, + fetcher, + initialData: "cached", + initialStatus: "ready", + scopeKey: "a", + }) + ) + ); + + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "cached" + ); + expect(container.firstElementChild?.getAttribute("data-status")).toBe( + "ready" + ); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it("joins equal non-superseding loads", async () => { + const request = deferred(); + const fetcher = vi.fn().mockReturnValue(request.promise); + act(() => + root.render( + createElement(Harness, { + autoLoad: false, + fetcher, + scopeKey: "a", + }) + ) + ); + + let first!: Promise; + let second!: Promise; + act(() => { + first = current.reload(); + second = current.reload(); + }); + expect(fetcher).toHaveBeenCalledTimes(1); + + request.resolve("joined"); + await act(async () => Promise.all([first, second])); + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "joined" + ); + }); + + it("recovers from error and resets when disabled", async () => { + const fetcher = vi + .fn<(scopeKey: string) => Promise>() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce("recovered"); + act(() => root.render(createElement(Harness, { fetcher, scopeKey: "a" }))); + await flush(); + expect(container.firstElementChild?.getAttribute("data-error")).toBe( + "offline" + ); + + await act(async () => current.refresh()); + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "recovered" + ); + + act(() => + root.render( + createElement(Harness, { + enabled: false, + fetcher, + scopeKey: "a", + }) + ) + ); + expect(container.firstElementChild?.getAttribute("data-value")).toBe(""); + expect(container.firstElementChild?.getAttribute("data-status")).toBe( + "idle" + ); + }); +}); diff --git a/src/hooks/async/useAsyncResource.ts b/src/hooks/async/useAsyncResource.ts new file mode 100644 index 000000000..e08e5cf93 --- /dev/null +++ b/src/hooks/async/useAsyncResource.ts @@ -0,0 +1,234 @@ +import { + type Dispatch, + type SetStateAction, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import { LatestScopedTask } from "@src/util/core/latestScopedTask"; + +export type AsyncResourceStatus = + | "idle" + | "loading" + | "ready" + | "refreshing" + | "error"; + +interface AsyncResourceState { + data: T; + error: string | null; + scopeKey: string | null; + status: AsyncResourceStatus; +} + +export interface UseAsyncResourceOptions { + autoLoad?: boolean; + enabled?: boolean; + fetcher: ( + scopeKey: string, + context: AsyncResourceFetchContext + ) => Promise; + initialData: T; + initialStatus?: "idle" | "ready"; + scopeKey: string | null; +} + +export interface AsyncResourceFetchContext { + cause: "background" | "load" | "refresh"; + isCurrent(): boolean; + /** Commit an intermediate cache/stale-while-revalidate value if still current. */ + publish(data: T, options?: { keepLoading?: boolean }): void; +} + +export interface AsyncResourceReloadOptions { + /** Keep the current loading indicator unchanged, for background revalidation. */ + background?: boolean; + /** Start a new generation instead of joining an equal in-flight scope. */ + supersede?: boolean; +} + +export interface UseAsyncResourceResult { + data: T; + error: string | null; + loading: boolean; + refreshing: boolean; + reload: (options?: AsyncResourceReloadOptions) => Promise; + refresh: () => Promise; + setData: Dispatch>; + status: AsyncResourceStatus; +} + +/** + * Own one scope-fenced async resource. + * + * Equal automatic loads share an in-flight promise. Manual refreshes start a + * new generation, and every completion verifies that its scope/generation is + * still current before committing state. + */ +export function useAsyncResource({ + autoLoad = true, + enabled = true, + fetcher, + initialData, + initialStatus = "idle", + scopeKey, +}: UseAsyncResourceOptions): UseAsyncResourceResult { + const coordinator = useMemo(() => new LatestScopedTask(), []); + const initialDataRef = useRef(initialData); + initialDataRef.current = initialData; + const initialStatusRef = useRef(initialStatus); + initialStatusRef.current = initialStatus; + const [state, setState] = useState>({ + data: initialData, + error: null, + scopeKey: null, + status: initialStatus, + }); + + const reload = useCallback( + async ({ + background = false, + supersede = false, + }: AsyncResourceReloadOptions = {}) => { + if (!enabled || !scopeKey) return; + if (supersede) coordinator.supersede(); + + setState((current) => { + const isCurrentScope = current.scopeKey === scopeKey; + const data = isCurrentScope ? current.data : initialDataRef.current; + if (background && isCurrentScope && current.status === "ready") { + return { ...current, error: null }; + } + return { + data, + error: null, + scopeKey, + status: + isCurrentScope && current.status !== "idle" + ? "refreshing" + : "loading", + }; + }); + + await coordinator.run(scopeKey, async (context) => { + try { + const publish = (data: T, options?: { keepLoading?: boolean }) => { + if (context.isCurrent()) { + setState((current) => ({ + data, + error: null, + scopeKey, + status: options?.keepLoading ? current.status : "ready", + })); + } + }; + const cause = background + ? "background" + : supersede + ? "refresh" + : "load"; + const data = await fetcher(scopeKey, { + cause, + isCurrent: context.isCurrent, + publish, + }); + if (context.isCurrent()) { + setState({ + data, + error: null, + scopeKey, + status: "ready", + }); + } + } catch (error) { + if (context.isCurrent()) { + setState((current) => ({ + data: + current.scopeKey === scopeKey + ? current.data + : initialDataRef.current, + error: error instanceof Error ? error.message : String(error), + scopeKey, + status: "error", + })); + } + } + }); + }, + [coordinator, enabled, fetcher, scopeKey] + ); + + useEffect(() => { + coordinator.supersede(); + if (!enabled || !scopeKey) { + setState({ + data: initialDataRef.current, + error: null, + scopeKey: null, + status: initialStatusRef.current, + }); + return undefined; + } + + if (autoLoad) { + void reload(); + } else { + setState({ + data: initialDataRef.current, + error: null, + scopeKey, + status: initialStatusRef.current, + }); + } + + return () => { + coordinator.supersede(); + }; + }, [autoLoad, coordinator, enabled, reload, scopeKey]); + + const visibleState = + enabled && scopeKey && state.scopeKey === scopeKey + ? state + : { + data: initialDataRef.current, + error: null, + scopeKey: null, + status: initialStatusRef.current, + }; + + const setData = useCallback>>( + (next) => { + if (!enabled || !scopeKey) return; + setState((current) => { + const currentData = + current.scopeKey === scopeKey ? current.data : initialDataRef.current; + return { + ...current, + data: + typeof next === "function" + ? (next as (current: T) => T)(currentData) + : next, + scopeKey, + }; + }); + }, + [enabled, scopeKey] + ); + + const refresh = useCallback(() => reload({ supersede: true }), [reload]); + + return { + data: visibleState.data, + error: visibleState.error, + loading: + visibleState.status === "loading" || visibleState.status === "refreshing", + refreshing: visibleState.status === "refreshing", + reload, + refresh, + setData, + status: visibleState.status, + }; +} diff --git a/src/hooks/async/useVisibilityPolledData.test.ts b/src/hooks/async/useVisibilityPolledData.test.ts new file mode 100644 index 000000000..acac91c1c --- /dev/null +++ b/src/hooks/async/useVisibilityPolledData.test.ts @@ -0,0 +1,203 @@ +// @vitest-environment jsdom +import { act, createElement, useEffect } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { + type UseVisibilityPolledDataResult, + useVisibilityPolledData, +} from "./useVisibilityPolledData"; + +const pollMocks = vi.hoisted(() => ({ + start: vi.fn(), +})); + +vi.mock("@src/util/core/visibilityAwarePoll", () => ({ + startVisibilityAwarePoll: pollMocks.start, +})); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + }); +} + +describe("useVisibilityPolledData", () => { + let container: HTMLDivElement; + let root: Root; + let current: UseVisibilityPolledDataResult; + let pollTasks: Array<() => Promise | void>; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + function Harness({ + enabled = true, + fetcher, + scopeKey, + }: { + enabled?: boolean; + fetcher: (scopeKey: string) => Promise; + scopeKey: string | null; + }) { + const result = useVisibilityPolledData({ + enabled, + fetcher, + initialData: "", + intervalMs: 1_500, + scopeKey, + }); + useEffect(() => { + current = result; + }, [result]); + return createElement("div", { + "data-error": result.error ?? "", + "data-loading": String(result.loading), + "data-value": result.data, + }); + } + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + pollTasks = []; + pollMocks.start.mockReset().mockImplementation((options) => { + pollTasks.push(options.task); + if (options.runImmediately) void options.task(); + return { runNow: vi.fn(), stop: vi.fn() }; + }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("loads once, then refreshes in the background without clearing data", async () => { + const first = deferred(); + const second = deferred(); + const fetcher = vi + .fn<(scopeKey: string) => Promise>() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + act(() => root.render(createElement(Harness, { fetcher, scopeKey: "a" }))); + expect(container.firstElementChild?.getAttribute("data-loading")).toBe( + "true" + ); + + first.resolve("first"); + await flush(); + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "first" + ); + expect(container.firstElementChild?.getAttribute("data-loading")).toBe( + "false" + ); + + let background!: Promise; + act(() => { + background = Promise.resolve(pollTasks.at(-1)?.()); + }); + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "first" + ); + expect(container.firstElementChild?.getAttribute("data-loading")).toBe( + "false" + ); + + second.resolve("second"); + await act(async () => background); + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "second" + ); + }); + + it("drops a late response after switching scope", async () => { + const first = deferred(); + const second = deferred(); + const fetcher = vi + .fn<(scopeKey: string) => Promise>() + .mockImplementation((scope) => + scope === "a" ? first.promise : second.promise + ); + + act(() => root.render(createElement(Harness, { fetcher, scopeKey: "a" }))); + act(() => root.render(createElement(Harness, { fetcher, scopeKey: "b" }))); + + second.resolve("new scope"); + await flush(); + first.resolve("old scope"); + await flush(); + + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "new scope" + ); + }); + + it("recovers from failure through manual refresh", async () => { + const failed = deferred(); + const fetcher = vi + .fn<(scopeKey: string) => Promise>() + .mockReturnValueOnce(failed.promise) + .mockResolvedValueOnce("recovered"); + + act(() => root.render(createElement(Harness, { fetcher, scopeKey: "a" }))); + failed.reject(new Error("offline")); + await flush(); + expect(container.firstElementChild?.getAttribute("data-error")).toBe( + "offline" + ); + + await act(async () => current.refresh()); + expect(container.firstElementChild?.getAttribute("data-value")).toBe( + "recovered" + ); + expect(container.firstElementChild?.getAttribute("data-error")).toBe(""); + }); + + it("clears scoped data and stops polling when disabled", async () => { + const fetcher = vi.fn().mockResolvedValue("loaded"); + act(() => root.render(createElement(Harness, { fetcher, scopeKey: "a" }))); + await flush(); + + act(() => + root.render( + createElement(Harness, { enabled: false, fetcher, scopeKey: "a" }) + ) + ); + + expect(container.firstElementChild?.getAttribute("data-value")).toBe(""); + expect(container.firstElementChild?.getAttribute("data-loading")).toBe( + "false" + ); + }); +}); diff --git a/src/hooks/async/useVisibilityPolledData.ts b/src/hooks/async/useVisibilityPolledData.ts new file mode 100644 index 000000000..929f7bc3d --- /dev/null +++ b/src/hooks/async/useVisibilityPolledData.ts @@ -0,0 +1,68 @@ +import { useEffect } from "react"; + +import { startVisibilityAwarePoll } from "@src/util/core/visibilityAwarePoll"; + +import { useAsyncResource } from "./useAsyncResource"; + +export interface UseVisibilityPolledDataOptions { + enabled: boolean; + fetcher: (scopeKey: string) => Promise; + initialData: T; + intervalMs: number; + scopeKey: string | null; +} + +export interface UseVisibilityPolledDataResult { + data: T; + error: string | null; + loading: boolean; + refresh: () => Promise; +} + +/** + * Own one visibility-aware, scope-fenced polling resource. + * + * The first load and manual refresh expose loading state. Background ticks + * retain the current data without flashing the loading indicator. + */ +export function useVisibilityPolledData({ + enabled, + fetcher, + initialData, + intervalMs, + scopeKey, +}: UseVisibilityPolledDataOptions): UseVisibilityPolledDataResult { + const resource = useAsyncResource({ + autoLoad: false, + enabled, + fetcher, + initialData, + scopeKey, + }); + const { reload } = resource; + + useEffect(() => { + if (!enabled || !scopeKey) return undefined; + + let initialLoad = true; + const poll = startVisibilityAwarePoll({ + intervalMs, + runImmediately: true, + task: () => { + const background = !initialLoad; + initialLoad = false; + return reload({ background }); + }, + }); + return () => { + poll.stop(); + }; + }, [enabled, intervalMs, reload, scopeKey]); + + return { + data: resource.data, + error: resource.error, + loading: resource.loading, + refresh: resource.refresh, + }; +} diff --git a/src/util/core/__tests__/latestScopedTask.test.ts b/src/util/core/__tests__/latestScopedTask.test.ts new file mode 100644 index 000000000..022f8cb38 --- /dev/null +++ b/src/util/core/__tests__/latestScopedTask.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; + +import { LatestScopedTask } from "../latestScopedTask"; + +describe("LatestScopedTask", () => { + it("shares one promise for the same scope", async () => { + const coordinator = new LatestScopedTask(); + const operation = vi.fn().mockResolvedValue("done"); + + const first = coordinator.run("same", operation); + const second = coordinator.run("same", operation); + + expect(first).toBe(second); + await expect(first).resolves.toBe("done"); + expect(operation).toHaveBeenCalledTimes(1); + }); + + it("marks an older scope stale as soon as a newer scope starts", async () => { + const coordinator = new LatestScopedTask(); + let oldIsCurrent!: () => boolean; + let release!: () => void; + const old = coordinator.run( + "old", + (context) => + new Promise((resolve) => { + oldIsCurrent = context.isCurrent; + release = resolve; + }) + ); + + await coordinator.run("new", async (context) => { + expect(context.isCurrent()).toBe(true); + }); + expect(oldIsCurrent()).toBe(false); + release(); + await old; + }); + + it("retries a scope after failure", async () => { + const coordinator = new LatestScopedTask(); + const operation = vi + .fn() + .mockRejectedValueOnce(new Error("failed")) + .mockResolvedValueOnce("retried"); + + await expect(coordinator.run("scope", operation)).rejects.toThrow("failed"); + await expect(coordinator.run("scope", operation)).resolves.toBe("retried"); + expect(operation).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/util/core/__tests__/visibilityAwarePoll.test.ts b/src/util/core/__tests__/visibilityAwarePoll.test.ts new file mode 100644 index 000000000..3e16f5cdc --- /dev/null +++ b/src/util/core/__tests__/visibilityAwarePoll.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + type PollEnvironment, + startVisibilityAwarePoll, +} from "../visibilityAwarePoll"; + +function createEnvironment() { + let visible = true; + let visibilityListener: (() => void) | undefined; + const environment: PollEnvironment = { + clearTimer: (timer) => clearTimeout(timer as ReturnType), + isVisible: () => visible, + scheduleTimer: (callback, delayMs) => setTimeout(callback, delayMs), + subscribeToVisibilityChange: (callback) => { + visibilityListener = callback; + return () => { + visibilityListener = undefined; + }; + }, + }; + return { + environment, + setVisible(next: boolean) { + visible = next; + visibilityListener?.(); + }, + }; +} + +describe("startVisibilityAwarePoll", () => { + it("waits for the active task before scheduling another pass", async () => { + vi.useFakeTimers(); + const { environment } = createEnvironment(); + let release!: () => void; + const task = vi.fn( + () => + new Promise((resolve) => { + release = resolve; + }) + ); + const poll = startVisibilityAwarePoll({ + environment, + intervalMs: 2_000, + task, + }); + + await vi.advanceTimersByTimeAsync(2_000); + expect(task).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(10_000); + expect(task).toHaveBeenCalledTimes(1); + + release(); + await vi.runAllTicks(); + await vi.advanceTimersByTimeAsync(2_000); + expect(task).toHaveBeenCalledTimes(2); + + poll.stop(); + vi.useRealTimers(); + }); + + it("drops its timer while hidden and catches up once on visibility", async () => { + vi.useFakeTimers(); + const controlled = createEnvironment(); + const task = vi.fn().mockResolvedValue(undefined); + const poll = startVisibilityAwarePoll({ + environment: controlled.environment, + intervalMs: 2_000, + task, + }); + + controlled.setVisible(false); + await vi.advanceTimersByTimeAsync(10_000); + expect(task).not.toHaveBeenCalled(); + + controlled.setVisible(true); + await vi.runAllTicks(); + expect(task).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(2_000); + expect(task).toHaveBeenCalledTimes(2); + + poll.stop(); + vi.useRealTimers(); + }); + + it("does not reschedule after stop while a task is settling", async () => { + vi.useFakeTimers(); + const { environment } = createEnvironment(); + let release!: () => void; + const task = vi.fn( + () => + new Promise((resolve) => { + release = resolve; + }) + ); + const poll = startVisibilityAwarePoll({ + environment, + intervalMs: 2_000, + runImmediately: true, + task, + }); + + expect(task).toHaveBeenCalledTimes(1); + poll.stop(); + release(); + await vi.runAllTicks(); + await vi.advanceTimersByTimeAsync(10_000); + expect(task).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); +}); diff --git a/src/util/core/latestScopedTask.ts b/src/util/core/latestScopedTask.ts new file mode 100644 index 000000000..ddec2d3a5 --- /dev/null +++ b/src/util/core/latestScopedTask.ts @@ -0,0 +1,49 @@ +export interface ScopedTaskContext { + readonly generation: number; + isCurrent(): boolean; +} + +interface ActiveScopedTask { + key: string; + promise: Promise; +} + +/** + * Join equal async scopes while allowing a changed scope to supersede them. + * + * Callers use `context.isCurrent()` before committing results so late + * responses from a previous filter/project cannot overwrite newer state. + */ +export class LatestScopedTask { + private active?: ActiveScopedTask; + private generation = 0; + + run( + key: string, + operation: (context: ScopedTaskContext) => Promise + ): Promise { + if (this.active?.key === key) { + return this.active.promise as Promise; + } + + const generation = ++this.generation; + const context: ScopedTaskContext = { + generation, + isCurrent: () => this.generation === generation, + }; + const promise = operation(context); + this.active = { key, promise }; + const release = () => { + if (this.active?.promise === promise) { + this.active = undefined; + } + }; + void promise.then(release, release); + return promise; + } + + supersede(): void { + this.generation += 1; + this.active = undefined; + } +} diff --git a/src/util/core/visibilityAwarePoll.ts b/src/util/core/visibilityAwarePoll.ts new file mode 100644 index 000000000..d50ca196d --- /dev/null +++ b/src/util/core/visibilityAwarePoll.ts @@ -0,0 +1,120 @@ +export interface PollEnvironment { + clearTimer(timer: unknown): void; + isVisible(): boolean; + scheduleTimer(callback: () => void, delayMs: number): unknown; + subscribeToVisibilityChange(callback: () => void): () => void; +} + +export interface VisibilityAwarePollOptions { + environment?: PollEnvironment; + intervalMs: number; + onError?: (error: unknown) => void; + runImmediately?: boolean; + runOnVisible?: boolean; + task: () => Promise | void; +} + +export interface VisibilityAwarePollController { + runNow(): void; + stop(): void; +} + +function browserPollEnvironment(): PollEnvironment { + return { + clearTimer: (timer) => window.clearTimeout(timer as number), + isVisible: () => document.visibilityState !== "hidden", + scheduleTimer: (callback, delayMs) => window.setTimeout(callback, delayMs), + subscribeToVisibilityChange: (callback) => { + document.addEventListener("visibilitychange", callback); + return () => document.removeEventListener("visibilitychange", callback); + }, + }; +} + +/** + * Run a non-critical background task without overlapping executions. + * + * The next delay starts only after the previous task settles. Hidden pages + * retain no timer; becoming visible triggers one immediate catch-up pass. + */ +export function startVisibilityAwarePoll( + options: VisibilityAwarePollOptions +): VisibilityAwarePollController { + const environment = options.environment ?? browserPollEnvironment(); + const runOnVisible = options.runOnVisible ?? true; + let stopped = false; + let running = false; + let rerunRequested = false; + let timer: unknown; + + const clearScheduledTimer = () => { + if (timer === undefined) return; + environment.clearTimer(timer); + timer = undefined; + }; + + const schedule = () => { + if (stopped || running || timer !== undefined || !environment.isVisible()) { + return; + } + timer = environment.scheduleTimer(() => { + timer = undefined; + void run(); + }, options.intervalMs); + }; + + const run = async () => { + if (stopped || !environment.isVisible()) return; + if (running) { + rerunRequested = true; + return; + } + + clearScheduledTimer(); + running = true; + try { + await options.task(); + } catch (error) { + options.onError?.(error); + } finally { + running = false; + if (!stopped) { + if (rerunRequested && environment.isVisible()) { + rerunRequested = false; + void run(); + } else { + rerunRequested = false; + schedule(); + } + } + } + }; + + const unsubscribe = environment.subscribeToVisibilityChange(() => { + if (!environment.isVisible()) { + clearScheduledTimer(); + return; + } + if (runOnVisible) { + void run(); + } else { + schedule(); + } + }); + + if (options.runImmediately) { + void run(); + } else { + schedule(); + } + + return { + runNow: () => void run(), + stop: () => { + stopped = true; + rerunRequested = false; + clearScheduledTimer(); + unsubscribe(); + }, + }; +} From d16c8e541f6e6128d4169c31c8b8aad378b428bc Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Thu, 23 Jul 2026 22:32:27 +0800 Subject: [PATCH 2/2] docs(audit): record async lifecycle verdicts Pre-commit hook ran. Total eslint: 10, total circular: 0 --- .../AsyncResourceLifecycle.md | 109 ++++++++++++++++++ .../AsyncResourceConsumers.md | 42 +++++++ .../AsyncResourceLifecycle.md | 35 ++++++ 3 files changed, 186 insertions(+) create mode 100644 docs/architecture-audit-2026-07-23/AsyncResourceLifecycle.md create mode 100644 docs/frontend-ui-audit-2026-07-23/AsyncResourceConsumers.md create mode 100644 docs/org2-performance-guard-2026-07-23/AsyncResourceLifecycle.md diff --git a/docs/architecture-audit-2026-07-23/AsyncResourceLifecycle.md b/docs/architecture-audit-2026-07-23/AsyncResourceLifecycle.md new file mode 100644 index 000000000..f299b22a7 --- /dev/null +++ b/docs/architecture-audit-2026-07-23/AsyncResourceLifecycle.md @@ -0,0 +1,109 @@ +# Architecture Audit — Async Resource Lifecycle + +**Scope:** shared async-resource state, visibility-aware polling, and the migrated TypeScript query owners. +**Date:** 2026-07-23 +**Auditor:** ORGII implementation session + +## Acceptance criteria + +- One owner for `data / error / loading / refreshing / reload` state. +- Equal in-flight scope loads coalesce; explicit refresh starts a new generation. +- A completion from an old repo, workspace, project, filter, language, or webview cannot commit. +- Disabled and changed scopes cannot display data from the previous scope. +- Background polling pauses while hidden, never overlaps, and stops cleanly. +- Mutation/action loading remains separate from query loading. +- Caches are bounded and include the complete resource identity. +- TypeScript, focused lint, lifecycle tests, and `git diff --check` pass. + +## Layer 1 — Compilation correctness + +- `pnpm run typecheck`: passed. +- Focused ESLint across all changed frontend files: passed. +- Focused Vitest run: 10 files and 118 tests passed. +- `git diff --check`: passed. + +## Layer 2 — Dead code and structural deduplication + +- Removed the unused `useAsyncData` abstraction. +- `useAsyncResource` is now the single generic owner for request state and generation fencing. +- `useVisibilityPolledData` composes the same owner instead of maintaining a second polling-specific state machine. +- Duplicate initial-load/manual-refresh implementations were removed from the migrated hooks. + +## Layer 3 — Naming consistency + +| Term | Meaning | Verdict | +| --- | --- | --- | +| `scopeKey` | Complete identity of the visible resource | Explicit and consistent | +| `reload` | Load or background-revalidate, joining an equal in-flight generation | Explicit | +| `refresh` | User-requested superseding generation | Explicit | +| `loading` | Initial load or foreground refresh | Kept for consumer compatibility | +| `refreshing` | Existing data is retained during foreground refresh | Separate state, not overloaded with action progress | +| `operationLoading` / `gatewayLoading` | Explicit user mutation in progress | Correctly remains outside the query resource | + +## Layer 4 — Semantic overloading + +- Query state and mutation state remain separate in Stash, Gateway, Work Item, and provider flows. +- `background` controls presentation only; it does not weaken generation checks. +- `publish` means an intermediate current-scope cache value, not completion. +- `setData` is limited to optimistic/current-resource updates and cannot write while the resource is disabled. + +## Layer 5 — Default branch analysis + +| Condition | Result | +| --- | --- | +| `enabled === false` or `scopeKey === null` | Reset to initial data/status and supersede active work | +| Same automatic scope already in flight | Join the existing promise | +| Manual refresh | Supersede and start a new generation | +| Scope changes | Hide old data immediately and reject late completion | +| Fetch rejects | Preserve current-scope data, expose normalized error | +| Hidden document | Retain no polling timer | +| Visibility returns | Run one immediate catch-up pass | +| Poll stops during DOM dirty-check | Effect-local active fence prevents a post-teardown reload | + +## Layer 6 — Cross-domain concept leakage + +- `useAsyncResource`, `LatestScopedTask`, and `startVisibilityAwarePoll` contain no project, Git, LSP, provider, or webview domain imports. +- Domain fetchers own payload parsing and cache policy; the generic lifecycle layer owns only scheduling and commit eligibility. +- Tauri and HTTP command names remain at their domain call sites. + +## Layer 7 — New developer confusion test + +- The hook contract documents the difference between automatic load, manual refresh, background reload, and intermediate cache publish. +- A resource's visible state is derived from the current `scopeKey`; consumers do not need local cancellation refs. +- Action states are visibly named and remain local where they represent distinct user operations. + +## Layer 8 — Wire protocol and serialization + +- No backend command schema or wire payload was changed. +- Serialized scope keys are frontend-only coordinator identities and are parsed by the matching local fetcher. +- Scope keys include all relevant identity fields, including repo ID/path, connection/team/surface, filter, language, and webview label/depth. + +## Layer 9 — Init parity + +| Entry path | Resource owner | Generation guard | Error normalization | +| --- | --- | ---: | ---: | +| Automatic first load | `useAsyncResource` | yes | yes | +| Manual refresh | `useAsyncResource.refresh` | yes, superseding | yes | +| Background poll | `reload({ background: true })` | yes | yes | +| Cache then live result | `context.publish` + final return | yes | yes | +| Disabled/unmounted scope | effect cleanup | yes | n/a | + +## Layer 10 — Resolver symmetry + +- Every migrated resource uses the same scope value for loading, visibility, stale-result rejection, and optimistic updates. +- Cached and live values use the same resource identity. +- The commit-diff cache now includes repository identity as well as commit SHA, eliminating cross-repository collisions. + +## Systematic sweep + +- Searched query hooks for repeated `loading/error/data` owners and manual cancellation/request-ID patterns. +- Searched active runtime code for `setInterval`; migrated non-critical IPC polling peers. +- Kept animation clocks, debounces, durable persistence heartbeats, editor/document FSMs, user-triggered searches, and mutation progress states with their specialized owners. +- Remaining literal `setInterval` hits in the audited directories are UI clocks/simulations or domain-specific lifecycles, not duplicate query polling. + +## Completion verdict + +- One teardown issue found during audit was fixed: DOM dirty polling now cannot schedule a tree reload after its effect has stopped. +- Relevant architecture layers 1–10 were checked; backend init, schema migration, and resolver-chain changes were not applicable because no backend or wire contract changed. + +**Architecture verdict: pass for the audited async-resource and polling scope.** diff --git a/docs/frontend-ui-audit-2026-07-23/AsyncResourceConsumers.md b/docs/frontend-ui-audit-2026-07-23/AsyncResourceConsumers.md new file mode 100644 index 000000000..d2626be93 --- /dev/null +++ b/docs/frontend-ui-audit-2026-07-23/AsyncResourceConsumers.md @@ -0,0 +1,42 @@ +# Frontend UI Audit — Async Resource Consumers + +**Files:** `src/engines/ChatPanel/panels/ProjectPanelView.tsx`, `src/modules/ProjectManager/LinearProjects/useLinearIndexData.tsx` +**Date:** 2026-07-23 +**Auditor:** ORGII implementation session + +The diff in both files changes data ownership only. It does not add or modify rendered JSX, class names, interactive elements, or layout. + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| Changed hunks | none | keep | The refactor introduces no raw interactive or structural HTML. | — | + +## D2 — Arbitrary Tailwind Value vs Token + +| Line | Value | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| Changed hunks | none | keep | No Tailwind or CSS-variable class changed. | — | + +## D3 — Hardcoded Sizes / Colors + +| Line | Value | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| Changed hunks | none | keep | No size or color literal changed. | — | + +## D4 — Accessibility + +| Line | Element | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| Changed hunks | none | keep | No rendered element or interaction contract changed. | — | + +## D5 — Visual Patterns Observed + +- No new visual pattern was introduced. +- The shared abstraction is a data-lifecycle hook and is not a design-system component candidate. + +## Summary + +- 0 fixes recommended +- 0 kept exceptions requiring future review +- 0 abstract UI candidates diff --git a/docs/org2-performance-guard-2026-07-23/AsyncResourceLifecycle.md b/docs/org2-performance-guard-2026-07-23/AsyncResourceLifecycle.md new file mode 100644 index 000000000..549985ff2 --- /dev/null +++ b/docs/org2-performance-guard-2026-07-23/AsyncResourceLifecycle.md @@ -0,0 +1,35 @@ +# Performance Guard — Async Resource Lifecycle + +**Scope:** migrated resource fetches, background polling, request caches, and multi-scope lifecycle. +**Date:** 2026-07-23 + +## Lifecycle matrix + +| State | Required behavior | Audited behavior | +| --- | --- | --- | +| Visible and active | Fetch on demand at configured cadence | Recursive polling; next delay starts after settlement | +| Visible and idle | Avoid full reload unless dirty or scheduled safety refresh | DOM uses dirty-check; other retained polls are bounded safety refreshes | +| Hidden | No non-critical polling timer | Timer cleared; visibility return triggers one catch-up pass | +| Scope switch | Previous data and completion cannot appear | Complete scope key plus generation fence | +| Unmount/disable | Stop timer/listener and reject late commits | Poll controller cleanup plus coordinator supersede | +| Offline/error | Set current resource error without a retry storm | No automatic tight retry; configured cadence resumes | +| Repeated mount | No app-lifetime accumulation | Per-hook coordinator owns one active promise; listeners/timers dispose | + +## Findings and evidence + +| Area | Verdict | Evidence | Change or reason kept | Verification | +| --- | --- | --- | --- | --- | +| Background work | fix | DOM, Inspector, Console, Network, LSP, Git auto-fetch, and Gateway used or consumed polling | Replaced non-critical intervals with visibility-aware non-overlapping recursive polling; DOM teardown received an additional active fence | Visibility controller tests plus focused lifecycle review | +| Memory | fix/keep | Resource retains one state and one active promise; Console cache is 10 sessions × 500 rows, Network is 10 × 200, commit cache is 50 | Preserved existing caps; removed duplicate per-resource state; no new unbounded collection | Unit tests, code inspection | +| Scope/isolation | fix | Previous implementations used local mounted/cancelled flags or incomplete commit cache identity | Complete scope keys and generations now gate every commit; commit cache includes repo identity | Stale-filter, stale-scope, superseding-refresh tests | +| Rendering/hot path | fix | Foreground state was repeatedly toggled by background refreshes | Background reload retains data and avoids spinner flashes; derived grouping remains memoized | Async-resource and polling hook tests | + +## Verification + +- `pnpm run typecheck`: passed. +- Focused ESLint: passed. +- Focused Vitest: 10 files, 118 tests passed. +- `git diff --check`: passed. +- Real Tauri visible/hidden IPC and CPU measurement was not run in this audit environment. + +**Performance verdict: blocked only on real Tauri runtime measurement; static lifecycle gates, compilation, lint, and focused regression tests pass.**