@lynx-go/ui-kit 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * kernel —— 元概念的契约与纯代数(零依赖、零 react import)。
3
+ *
4
+ * 三个交互原语(问/令/听)+ 一个交互面(Resource)。
5
+ * 设计依据:docs/design.md §2。
6
+ */
7
+
8
+ // ============ 流几何 ============
9
+
10
+ /** 不透明定位令牌;collection 的 cursor window 与 subscription 共享。 */
11
+ export type Cursor = string;
12
+
13
+ export type FieldValue = string | number | boolean | ReadonlyArray<string>;
14
+
15
+ export type Operator = "eq" | "ne" | "contains" | "in" | "gt" | "lt" | "gte" | "lte" | "exists";
16
+
17
+ /** 构造侧类型化(可收窄为 `keyof T & string`)、解码侧宽容(string)。 */
18
+ type FieldKeyBrand = string & { readonly __fieldKey?: unique symbol };
19
+ export type FieldKey<F extends string = string> = F | FieldKeyBrand;
20
+
21
+ export interface QueryWindow {
22
+ /** 必传(torchwood 契约:pageSize 缺省即截断事故) */
23
+ size: number;
24
+ /** keyset 方言:不透明续读令牌 */
25
+ cursor?: Cursor;
26
+ /** offset 方言(配合 Segment.total) */
27
+ range?: { offset: number };
28
+ }
29
+
30
+ export interface Order {
31
+ field: FieldKey;
32
+ dir: "asc" | "desc";
33
+ }
34
+
35
+ export type Predicate =
36
+ | { field: FieldKey; op: Operator; value: FieldValue }
37
+ | { all: ReadonlyArray<Predicate> }
38
+ | { any: ReadonlyArray<Predicate> };
39
+
40
+ /** 集合查询的结构化意图。分页只是 window 的字段,不是概念。 */
41
+ export interface CollectionQuery {
42
+ predicate?: Predicate;
43
+ order?: ReadonlyArray<Order>;
44
+ window: QueryWindow;
45
+ }
46
+
47
+ /** 查询结果段。字段命名刻意与 useInfiniteQuery 的 getNextPageParam 对齐。 */
48
+ export interface Segment<T> {
49
+ rows: ReadonlyArray<T>;
50
+ /** offset 方言必有;leaderboards 型 keyset 也附带 */
51
+ total?: number;
52
+ /** 仅还有下页时出现("返回数 < size"不代表到底) */
53
+ nextCursor?: Cursor;
54
+ }
55
+
56
+ // ============ 失败通道(封闭集) ============
57
+ // 失败 = 无法产出领域可用值的传输/会话/协议层状况。
58
+ // 网络断层是 typed failure(可捕获、可重试、可渲染),不是 defect——defect 只留程序错误(§5 缝 1)。
59
+
60
+ export type ApiError =
61
+ | { kind: "network"; cause?: unknown }
62
+ | { kind: "auth" }
63
+ | { kind: "forbidden" }
64
+ | { kind: "not-found" }
65
+ | { kind: "server"; status: number; message?: string }
66
+ | { kind: "decode"; detail: string };
67
+
68
+ const API_ERROR_KINDS = new Set(["network", "auth", "forbidden", "not-found", "server", "decode"]);
69
+
70
+ export function isApiError(value: unknown): value is ApiError {
71
+ return (
72
+ typeof value === "object" &&
73
+ value !== null &&
74
+ API_ERROR_KINDS.has((value as { kind?: unknown }).kind as string)
75
+ );
76
+ }
77
+
78
+ /** 封闭集的配套:绑定为 Query/Mutation 注入的默认重试分类。 */
79
+ export function isRetryable(error: ApiError): boolean {
80
+ return error.kind === "network" || error.kind === "server";
81
+ }
82
+
83
+ // ============ 结果通道(已知分支 + ok 载荷兜底) ============
84
+
85
+ export type CommandResult<P = unknown> =
86
+ | { status: "ok"; payload: P }
87
+ | {
88
+ status: "rejected";
89
+ /** 机器可判别(双 console 后端都有 code/message——契约不能丢判别码) */
90
+ code?: string;
91
+ message: string;
92
+ fieldErrors?: Record<string, string>;
93
+ }
94
+ | { status: "conflict"; message: string };
95
+
96
+ // ============ 订阅(听) ============
97
+
98
+ export interface SubscriptionPolicy {
99
+ /** 环形缓冲容量(超限丢最旧) */
100
+ buffer: number;
101
+ pauseable?: boolean;
102
+ retry?: {
103
+ /** 退避序列,例 [1000, 2000, 4000]。退避耗尽 → stalled,轮询兜底是消费方组合 */
104
+ backoffMs: ReadonlyArray<number>;
105
+ };
106
+ }
107
+
108
+ /** 终态分诊归 transport:票据/base64/E_EVENTS_GONE/部署 state 解析全在消费方适配器。 */
109
+ export interface FrameSource<F> {
110
+ [Symbol.asyncIterator](): AsyncIterator<F>;
111
+ /** 迭代器 return(正常结束)时引擎读到的终结原因 */
112
+ readonly closeReason: "ended" | "gapped" | "auth" | "transport";
113
+ }
114
+
115
+ export interface Transport<F> {
116
+ open(cursor: Cursor | null, signal: AbortSignal): FrameSource<F>;
117
+ /** 重连前钩子(torchwood realtime 的"先 refresh 再重订");自带恢复的 transport 可不实现 */
118
+ beforeReconnect?(): Promise<void>;
119
+ }
120
+
121
+ export type SubscriptionStatus =
122
+ | "live"
123
+ | "reconnecting"
124
+ | "paused"
125
+ | "gapped"
126
+ | "stalled"
127
+ | "ended"
128
+ | "error";
@@ -0,0 +1,231 @@
1
+ import { keepPreviousData, useQuery } from "@tanstack/react-query";
2
+ import { useNavigate, useSearch } from "@tanstack/react-router";
3
+ import {
4
+ type ApiError,
5
+ advance,
6
+ type CollectionQuery,
7
+ type CommandsRecord,
8
+ type Order,
9
+ type Predicate,
10
+ type QueryWindow,
11
+ type Resource,
12
+ type ResourceScope,
13
+ type Segment,
14
+ type SubsRecord,
15
+ scoped,
16
+ } from "../kernel";
17
+ import type { FlatPatch, QuerySearchSchema } from "../schema";
18
+ import { batchQueryOptions, collectionQueryOptions, recordQueryOptions } from "./options";
19
+
20
+ /**
21
+ * useCollection —— collection Query ↔ URL ↔ Table 的绑定。
22
+ *
23
+ * 写入契约(design.md §2.1.1):
24
+ * - 一切 setter 走函数式 search(对象字面量会静默 strip 消费方自定义参数);
25
+ * - 翻页 = push(历史栈即分页栈);谓词/排序变化 = replace + 清游标回第一页;
26
+ * - 绑定不在渲染期 navigate。
27
+ */
28
+ export interface CollectionHandle<T> {
29
+ rows: ReadonlyArray<T>;
30
+ segment: Segment<T> | undefined;
31
+ /** 当前解码后的完整查询(URL + schema 默认值即真相) */
32
+ query: CollectionQuery;
33
+ status: "loading" | "empty" | "error" | "success";
34
+ error: ApiError | null;
35
+ total: number | undefined;
36
+ isFetching: boolean;
37
+ refetch: () => void;
38
+ /** replace + 回第一页 */
39
+ setPredicate(predicate: Predicate | null): void;
40
+ /** replace + 回第一页(keyset 游标与方向耦合) */
41
+ setOrder(order: ReadonlyArray<Order> | null): void;
42
+ /** push —— 历史即分页栈 */
43
+ setWindow(window: QueryWindow): void;
44
+ /** 游标推进;返回 false = 不可算(keyset prev 走浏览器历史) */
45
+ next(): boolean;
46
+ prev(): boolean;
47
+ reset(): void;
48
+ /** 逃生舱:直接打 URL 补丁(含消费方自定义参数) */
49
+ searchPatch(patch: FlatPatch, options?: { replace?: boolean }): void;
50
+ }
51
+
52
+ const EMPTY_ROWS: ReadonlyArray<never> = [];
53
+
54
+ export interface UseCollectionOptions {
55
+ /** 调用方路由 id(Route.id——search 解码锚定活跃 match;pathless 布局下与 fullPath 不同形) */
56
+ from: string;
57
+ /** collectionQuerySchema 的返回值(与路由 validateSearch 共用同一实例) */
58
+ search: QuerySearchSchema;
59
+ scope?: ResourceScope;
60
+ query?: {
61
+ staleTime?: number;
62
+ refetchInterval?: number | false;
63
+ enabled?: boolean;
64
+ /** 默认 true:翻页/过滤过渡不闪 loading */
65
+ keepPrevious?: boolean;
66
+ };
67
+ }
68
+
69
+ export function useCollection<T, ID, C extends CommandsRecord<ID>, S extends SubsRecord>(
70
+ resource: Resource<T, ID, C, S>,
71
+ options: UseCollectionOptions,
72
+ ): CollectionHandle<T> {
73
+ // from 是调用方路由的 id(useSearch 按路由 id 找活跃 match;pathless
74
+ // 布局下 Route.fullPath 与 id 不同形——fleetly 试点实咬,契约 = Route.id)。
75
+ const rawSearch = useSearch({ from: options.from as never, strict: false }) as unknown as Record<
76
+ string,
77
+ unknown
78
+ >;
79
+ // navigate 不带 from:TanStack Router 的 navigate({to:"."}) 把 from 当路径
80
+ // 模板解析(pathless 段不在路径里),而 useSearch 的 from 是路由 id——两个
81
+ // hook 的 from 语义不同。demo 顶层路由 id==path 掩盖了差异;嵌套/带参/
82
+ // pathless 路由下带 from 的 "." 解析会产出 "_shell/..." 假路径。不带 from
83
+ // 时 "." 对当前 match 的真实路径解析,全形态正确(写入契约 §2.1.1 不变:
84
+ // setter 只在事件期调用,不在渲染期)。
85
+ const navigate = useNavigate() as unknown as (opts: {
86
+ to: string;
87
+ search: (prev: Record<string, unknown>) => Record<string, unknown>;
88
+ replace?: boolean;
89
+ }) => Promise<void>;
90
+
91
+ const target = options.scope ? scoped(resource, options.scope) : resource;
92
+ const query = options.search.decode(rawSearch);
93
+ const keepPrevious = options.query?.keepPrevious !== false;
94
+
95
+ const result = useQuery<Segment<T>, ApiError>({
96
+ ...collectionQueryOptions(target, query, {
97
+ ...(options.query?.staleTime !== undefined ? { staleTime: options.query.staleTime } : {}),
98
+ ...(options.query?.refetchInterval !== undefined
99
+ ? { refetchInterval: options.query.refetchInterval }
100
+ : {}),
101
+ ...(options.query?.enabled !== undefined ? { enabled: options.query.enabled } : {}),
102
+ }),
103
+ ...(keepPrevious ? { placeholderData: keepPreviousData } : {}),
104
+ });
105
+
106
+ const searchPatch = (patch: FlatPatch, navOptions?: { replace?: boolean }) => {
107
+ void navigate({
108
+ to: ".",
109
+ search: (prev: Record<string, unknown>) => ({ ...prev, ...patch }),
110
+ replace: navOptions?.replace ?? false,
111
+ });
112
+ };
113
+
114
+ const setPredicate = (predicate: Predicate | null) => {
115
+ searchPatch(options.search.patch({ predicate: predicate ?? null }), { replace: true });
116
+ };
117
+ const setOrder = (order: ReadonlyArray<Order> | null) => {
118
+ searchPatch(options.search.patch({ order: order ?? null }), { replace: true });
119
+ };
120
+ const setWindow = (window: QueryWindow) => {
121
+ searchPatch(options.search.patch({ window }), { replace: false });
122
+ };
123
+ const next = (): boolean => {
124
+ const segment = result.data;
125
+ if (!segment) return false;
126
+ const w = advance(query.window, segment, "next");
127
+ if (!w) return false;
128
+ setWindow(w);
129
+ return true;
130
+ };
131
+ const prev = (): boolean => {
132
+ const segment = result.data;
133
+ if (!segment) return false;
134
+ const w = advance(query.window, segment, "prev");
135
+ if (!w) return false;
136
+ setWindow(w);
137
+ return true;
138
+ };
139
+ const reset = () => {
140
+ searchPatch(
141
+ {
142
+ q: undefined,
143
+ filter: undefined,
144
+ sort: undefined,
145
+ cursor: undefined,
146
+ page: undefined,
147
+ },
148
+ { replace: true },
149
+ );
150
+ };
151
+
152
+ const rows = result.data?.rows ?? EMPTY_ROWS;
153
+ const status: CollectionHandle<T>["status"] = result.isPending
154
+ ? "loading"
155
+ : result.isError
156
+ ? "error"
157
+ : rows.length === 0
158
+ ? "empty"
159
+ : "success";
160
+
161
+ return {
162
+ rows,
163
+ segment: result.data,
164
+ query,
165
+ status,
166
+ error: result.error ?? null,
167
+ total: result.data?.total,
168
+ isFetching: result.isFetching,
169
+ refetch: () => void result.refetch(),
170
+ setPredicate,
171
+ setOrder,
172
+ setWindow,
173
+ next,
174
+ prev,
175
+ reset,
176
+ searchPatch,
177
+ };
178
+ }
179
+
180
+ export interface RecordHandle<T> {
181
+ data: T | undefined;
182
+ status: "loading" | "error" | "success";
183
+ error: ApiError | null;
184
+ refetch: () => void;
185
+ }
186
+
187
+ export function useRecord<T, ID, C extends CommandsRecord<ID>, S extends SubsRecord>(
188
+ resource: Resource<T, ID, C, S>,
189
+ options: { id: ID | undefined; scope?: ResourceScope; query?: { staleTime?: number } },
190
+ ): RecordHandle<T> {
191
+ const target = options.scope ? scoped(resource, options.scope) : resource;
192
+ const result = useQuery<T, ApiError>({
193
+ ...recordQueryOptions(target, options.id, {
194
+ ...(options.query?.staleTime !== undefined ? { staleTime: options.query.staleTime } : {}),
195
+ }),
196
+ });
197
+ return {
198
+ data: result.data,
199
+ status: result.isPending ? "loading" : result.isError ? "error" : "success",
200
+ error: result.error ?? null,
201
+ refetch: () => void result.refetch(),
202
+ };
203
+ }
204
+
205
+ export interface BatchHandle<T, ID> {
206
+ rows: ReadonlyArray<T>;
207
+ byId: ReadonlyMap<ID, T>;
208
+ status: "loading" | "error" | "success";
209
+ }
210
+
211
+ export function useBatch<T, ID, C extends CommandsRecord<ID>, S extends SubsRecord>(
212
+ resource: Resource<T, ID, C, S>,
213
+ ids: ReadonlyArray<ID>,
214
+ options: { scope?: ResourceScope; query?: { staleTime?: number } } = {},
215
+ ): BatchHandle<T, ID> {
216
+ const target = options.scope ? scoped(resource, options.scope) : resource;
217
+ const result = useQuery<ReadonlyArray<T>, ApiError>({
218
+ ...batchQueryOptions(target, ids, {
219
+ ...(options.query?.staleTime !== undefined ? { staleTime: options.query.staleTime } : {}),
220
+ }),
221
+ });
222
+ const rows = result.data ?? EMPTY_ROWS;
223
+ const byId = new Map(
224
+ rows.map((row) => [target.getId ? target.getId(row) : (row as unknown as { id: ID }).id, row]),
225
+ );
226
+ return {
227
+ rows,
228
+ byId,
229
+ status: result.isPending ? "loading" : result.isError ? "error" : "success",
230
+ };
231
+ }
@@ -0,0 +1,122 @@
1
+ import { useForm } from "@tanstack/react-form";
2
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
3
+ import {
4
+ type ApiError,
5
+ type CommandDescriptor,
6
+ type CommandResult,
7
+ invalidationPredicate,
8
+ type QuerySelector,
9
+ type ResourceScope,
10
+ runCommand,
11
+ } from "../kernel";
12
+
13
+ /**
14
+ * useCommand —— Command 原语的绑定(问/令/听的"令")。
15
+ *
16
+ * - rejected/conflict 是**结果**(mutation.data),只有失败(网络/401/解码)走 mutation.error;
17
+ * - ok 后执行失效:descriptor.invalidates 声明优先,缺省 = 同资源全部查询(内置动词默认失效);
18
+ * selector 默认与命令 ctx scope 相交(project A 的命令不清 project B 的缓存);
19
+ * - treatAs 裁决在 kernel runCommand 单源完成;
20
+ * - 库不内置 toast:onSettled 回调交消费方(sonner 等)。
21
+ */
22
+ export interface UseCommandOptions<Payload> {
23
+ scope?: ResourceScope;
24
+ onSettled?: (result: CommandResult<Payload> | null, failure: ApiError | null) => void;
25
+ }
26
+
27
+ export interface CommandHandle<Input, Payload> {
28
+ mutate(input: Input): void;
29
+ mutateAsync(input: Input): Promise<CommandResult<Payload>>;
30
+ /** 最近一次结果(结果通道,非异常) */
31
+ result: CommandResult<Payload> | null;
32
+ pending: boolean;
33
+ /** 失败通道(ApiError)——与结果两分 */
34
+ failure: ApiError | null;
35
+ reset(): void;
36
+ }
37
+
38
+ export function useCommand<Input, Payload, ID = never>(
39
+ descriptor: CommandDescriptor<Input, Payload, ID>,
40
+ options: UseCommandOptions<Payload> = {},
41
+ ): CommandHandle<Input, Payload> {
42
+ const queryClient = useQueryClient();
43
+ const scope = options.scope ?? {};
44
+
45
+ const mutation = useMutation<CommandResult<Payload>, ApiError, Input>({
46
+ mutationFn: (input) => runCommand(descriptor, input, scope),
47
+ onSuccess: (result) => {
48
+ if (result.status === "ok") {
49
+ const selectors: ReadonlyArray<QuerySelector> =
50
+ descriptor.invalidates ??
51
+ (descriptor.__resource !== undefined ? [{ resource: descriptor.__resource }] : []);
52
+ if (selectors.length > 0) {
53
+ void queryClient.invalidateQueries({
54
+ predicate: invalidationPredicate(selectors, scope),
55
+ });
56
+ }
57
+ }
58
+ options.onSettled?.(result, null);
59
+ },
60
+ onError: (error) => options.onSettled?.(null, error),
61
+ });
62
+
63
+ return {
64
+ mutate: (input) => mutation.mutate(input),
65
+ mutateAsync: mutation.mutateAsync,
66
+ result: mutation.data ?? null,
67
+ pending: mutation.isPending,
68
+ failure: mutation.error ?? null,
69
+ reset: mutation.reset,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * useCommandForm —— Command × TanStack Form 的表单绑定(design.md §5.4)。
75
+ *
76
+ * - rejected → onSubmit 返回 { form: message, fields: fieldErrors }(v1 文档化路径;
77
+ * setErrorMap 已被 v2 alpha 删除——不建在被删的 API 上);
78
+ * - conflict → form 级消息;ok → 失效已在 useCommand 完成,reveal 载荷经绑定状态暴露
79
+ * (不进 Query 缓存、卸载即弃);
80
+ * - 服务端驱动 defaultValues 重播(dirty 不覆盖)由消费方经 key 重挂或 form.reset 实现(后续块内建)。
81
+ */
82
+ export interface UseCommandFormOptions<Input extends object, Payload> {
83
+ defaultValues: Input;
84
+ scope?: ResourceScope;
85
+ onSettled?: (result: CommandResult<Payload> | null, failure: ApiError | null) => void;
86
+ }
87
+
88
+ export function useCommandForm<Input extends object, Payload, ID = never>(
89
+ descriptor: CommandDescriptor<Input, Payload, ID>,
90
+ options: UseCommandFormOptions<Input, Payload>,
91
+ ) {
92
+ const command = useCommand(descriptor, {
93
+ scope: options.scope,
94
+ onSettled: options.onSettled,
95
+ });
96
+
97
+ const form = useForm({
98
+ defaultValues: options.defaultValues,
99
+ onSubmit: async ({ value }) => {
100
+ const result = await command.mutateAsync(value);
101
+ if (result.status === "rejected") {
102
+ return {
103
+ form: result.message,
104
+ ...(result.fieldErrors ? { fields: result.fieldErrors } : {}),
105
+ };
106
+ }
107
+ if (result.status === "conflict") {
108
+ return { form: result.message };
109
+ }
110
+ return undefined;
111
+ },
112
+ });
113
+
114
+ return {
115
+ form,
116
+ submitting: command.pending || form.state.isSubmitting,
117
+ result: command.result,
118
+ /** 一次性 reveal 载荷(ok payload):token/邀请等"只在这一帧存在"的值 */
119
+ reveal: command.result?.status === "ok" ? command.result.payload : undefined,
120
+ command,
121
+ };
122
+ }
@@ -0,0 +1,46 @@
1
+ export type {
2
+ SubscriptionEngine,
3
+ SubscriptionHandle,
4
+ SubscriptionState,
5
+ } from "../adapters/subscription";
6
+ export {
7
+ type BatchHandle,
8
+ type CollectionHandle,
9
+ type RecordHandle,
10
+ type UseCollectionOptions,
11
+ useBatch,
12
+ useCollection,
13
+ useRecord,
14
+ } from "./bindings";
15
+ export {
16
+ type CommandHandle,
17
+ type UseCommandFormOptions,
18
+ type UseCommandOptions,
19
+ useCommand,
20
+ useCommandForm,
21
+ } from "./command";
22
+ export {
23
+ createFrameInvalidator,
24
+ type FrameInvalidator,
25
+ type UseInvalidateOnFrameOptions,
26
+ useInvalidateOnFrame,
27
+ } from "./invalidate";
28
+ export {
29
+ batchQueryOptions,
30
+ collectionQueryOptions,
31
+ type QueryEngineOptions,
32
+ recordQueryOptions,
33
+ } from "./options";
34
+ export {
35
+ type CanHandle,
36
+ type IdentityLike,
37
+ type PermissionState,
38
+ permissionState,
39
+ useCan,
40
+ } from "./permissions";
41
+ export {
42
+ decodePreference,
43
+ type PreferenceCodec,
44
+ usePreference,
45
+ } from "./preferences";
46
+ export { type UseSubscriptionOptions, useSubscription } from "./subscription";
@@ -0,0 +1,81 @@
1
+ import { useQueryClient } from "@tanstack/react-query";
2
+ import { useEffect, useRef } from "react";
3
+ import type { SubscriptionEngine } from "../adapters/subscription";
4
+ import { invalidationPredicate, type QuerySelector, type ResourceScope } from "../kernel";
5
+
6
+ /**
7
+ * useInvalidateOnFrame —— 实时失效(design.md §3 派生表:帧到达 → invalidate(选择器))。
8
+ *
9
+ * 组合钩子(非新原语):订阅帧流 × Query 缓存失效。Selector 语义与 useCommand 的
10
+ * invalidates 完全同源(invalidationPredicate 单源);scope 缺省与本组件域相交。
11
+ *
12
+ * - filter:帧级谓词(如只对 deleted 帧失效)——过滤是纯函数,热路径零成本;
13
+ * - throttleMs:失效合并窗口(trailing 单射)。热日志流(>10 帧/s)建议给窗口,
14
+ * invalidateQueries 本身幂等且 active 查询去重,缺省 0 = 每帧即射(语义最小惊讶)。
15
+ */
16
+
17
+ export interface FrameInvalidator {
18
+ /** 帧已通过调用方过滤后入队(窗口内合并) */
19
+ ingest(): void;
20
+ /** 卸载/重建时丢弃未射的窗口(不给已卸载组件补射失效) */
21
+ cancel(): void;
22
+ }
23
+
24
+ /** 纯调度器(可单测;hook 只做 sub.onFrame × queryClient 接线)。 */
25
+ export function createFrameInvalidator(fire: () => void, throttleMs = 0): FrameInvalidator {
26
+ if (throttleMs <= 0) return { ingest: fire, cancel: () => undefined };
27
+ let timer: ReturnType<typeof setTimeout> | null = null;
28
+ return {
29
+ ingest() {
30
+ if (timer !== null) return;
31
+ timer = setTimeout(() => {
32
+ timer = null;
33
+ fire();
34
+ }, throttleMs);
35
+ },
36
+ cancel() {
37
+ if (timer !== null) {
38
+ clearTimeout(timer);
39
+ timer = null;
40
+ }
41
+ },
42
+ };
43
+ }
44
+
45
+ export interface UseInvalidateOnFrameOptions<F> {
46
+ scope?: ResourceScope;
47
+ /** 帧级谓词:只对满足的帧失效(缺省全部帧) */
48
+ filter?: (frame: F) => boolean;
49
+ /** 失效合并窗口 ms(缺省 0 = 每帧即射) */
50
+ throttleMs?: number;
51
+ }
52
+
53
+ export function useInvalidateOnFrame<F>(
54
+ sub: SubscriptionEngine<F>,
55
+ selectors: ReadonlyArray<QuerySelector>,
56
+ options: UseInvalidateOnFrameOptions<F> = {},
57
+ ): void {
58
+ const queryClient = useQueryClient();
59
+ // 回调/谓词经 ref 透传(标识每渲染变化不应重订帧回调)
60
+ const optionsRef = useRef(options);
61
+ optionsRef.current = options;
62
+ const selectorsKey = JSON.stringify(selectors);
63
+
64
+ // biome-ignore lint/correctness/useExhaustiveDependencies: selectors 数组字面量每渲染变化不应重订——经 selectorsKey 锚定
65
+ useEffect(() => {
66
+ const invalidator = createFrameInvalidator(() => {
67
+ void queryClient.invalidateQueries({
68
+ predicate: invalidationPredicate(selectors, optionsRef.current.scope ?? {}),
69
+ });
70
+ }, optionsRef.current.throttleMs ?? 0);
71
+ const unsubscribe = sub.onFrame((frame) => {
72
+ const filter = optionsRef.current.filter;
73
+ if (filter && !filter(frame)) return;
74
+ invalidator.ingest();
75
+ });
76
+ return () => {
77
+ unsubscribe();
78
+ invalidator.cancel();
79
+ };
80
+ }, [sub, queryClient, selectorsKey]);
81
+ }