@aiao/rxdb-plugin-search-react 0.0.20 → 0.0.22

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.
package/REVIEW.md ADDED
@@ -0,0 +1,23 @@
1
+ # `@aiao/rxdb-plugin-search-react` 代码评审
2
+
3
+ ## 结论
4
+
5
+ 🔴 不通过。`useSearch()` 宣称 source 变化时重建 handle,但实际把首次 source 永久锁进 ref;组件切换数据库/collection 后仍订阅和查询旧 source。
6
+
7
+ ## 评审基线
8
+
9
+ - 基线提交:`03a46a5d5992a958c19ae33d5fed15c9c3322021`
10
+ - 评审日期:2026-07-14
11
+ - 范围:React 搜索 hook、测试、公开入口;10 个文件,约 311 行 TS
12
+ - 自动校验:`lint`、`test`、`typecheck`、`build` 全部通过
13
+
14
+ ## 问题
15
+
16
+ | ID | 级别 | 位置 | 问题与影响 | 建议 |
17
+ | --- | --- | --- | --- | --- |
18
+ | SEARCH-REACT-001 | P1 | `src/use-search.ts:67` | `handleRef` 只在 null 时用 source 创建,订阅 effect 的依赖数组为空;rerender 传入新 source/options 时,hook 仍对旧 handle 调用 setQuery/loadMore,并显示旧数据库结果。源码注释“仅在 source 变化时重建”与实现相反。 | 用 source/稳定 options key 作为 handle 生命周期依赖,切换时先取消旧订阅并 destroy 旧 handle;增加 rerender source A→B 后只能调用 B 的测试。 |
19
+
20
+ ## 三端对称性
21
+
22
+ - 共享类型透传与 Vue/Angular 一致;运行时入口 `useSearch` 符合 React 约定。
23
+ - source 切换语义目前不可靠,是此三端组唯一 P1。
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * @packageDocumentation
7
7
  */
8
+ export type { SearchExecutionError, SearchHandle, SearchOptions, SearchResult, SearchState } from '@aiao/rxdb-plugin-search';
8
9
  export { useSearch } from './use-search.js';
9
10
  export type { SearchSourceLike, UseSearchReturn } from './use-search.js';
10
11
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,YAAY,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AAEH,YAAY,EACV,oBAAoB,EACpB,YAAY,EACZ,aAAa,EACb,YAAY,EACZ,WAAW,EACZ,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,YAAY,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC"}
package/dist/index.js CHANGED
@@ -2,24 +2,35 @@
2
2
  import { useCallback as e, useEffect as t, useMemo as n, useRef as r, useState as i } from "react";
3
3
  import { Subscription as a } from "rxjs";
4
4
  //#region src/use-search.ts
5
- function o(o, s) {
6
- let c = s?.initialQuery ?? "", l = r(null), u = r(null);
7
- l.current === null && (l.current = o.search(c, s));
8
- let d = l.current, [f, p] = i(c), [m, h] = i([]), [g, _] = i("idle"), [v, y] = i(void 0), [b, x] = i(!1);
5
+ function o(e, t) {
6
+ return e === t ? !0 : e === void 0 || t === void 0 || e.length !== t.length ? !1 : e.every((e, n) => e === t[n]);
7
+ }
8
+ function s(e, t) {
9
+ return e === t ? !0 : e === void 0 || t === void 0 ? !1 : e.debounce === t.debounce && e.pageSize === t.pageSize && e.snippetLength === t.snippetLength && e.initialQuery === t.initialQuery && o(e.collections, t.collections);
10
+ }
11
+ function c(e) {
12
+ let t = r(e);
13
+ return s(t.current, e) || (t.current = e), t.current;
14
+ }
15
+ function l(o, s) {
16
+ let l = c(s), u = l?.initialQuery ?? "", d = r(null), [f, p] = i(u), [m, h] = i([]), [g, _] = i("idle"), [v, y] = i(void 0), [b, x] = i(!1);
9
17
  t(() => {
10
- u.current !== null && (clearTimeout(u.current), u.current = null);
11
- let e = new a();
12
- return e.add(d.results$.subscribe((e) => h(e))), e.add(d.state$.subscribe((e) => _(e))), e.add(d.error$.subscribe((e) => y(e))), e.add(d.hasMore$.subscribe((e) => x(e))), () => {
13
- e.unsubscribe(), u.current = setTimeout(() => {
14
- d.destroy(), l.current = null, u.current = null;
15
- }, 0);
18
+ let e = o.search(u, l);
19
+ d.current = e, p(u);
20
+ let t = new a();
21
+ return t.add(e.results$.subscribe((e) => h(e))), t.add(e.state$.subscribe((e) => _(e))), t.add(e.error$.subscribe((e) => y(e))), t.add(e.hasMore$.subscribe((e) => x(e))), () => {
22
+ t.unsubscribe(), e.destroy(), d.current === e && (d.current = null);
16
23
  };
17
- }, []);
24
+ }, [
25
+ o,
26
+ l,
27
+ u
28
+ ]);
18
29
  let S = e((e) => {
19
- p(e), d.setQuery(e);
20
- }, [d]), C = e(() => {
21
- p(""), d.clear();
22
- }, [d]), w = e(() => d.retry(), [d]), T = e(() => d.loadMore(), [d]);
30
+ p(e), d.current?.setQuery(e);
31
+ }, []), C = e(() => {
32
+ p(""), d.current?.clear();
33
+ }, []), w = e(() => d.current?.retry(), []), T = e(() => d.current?.loadMore() ?? Promise.resolve(), []);
23
34
  return n(() => ({
24
35
  query: f,
25
36
  setQuery: S,
@@ -43,4 +54,4 @@ function o(o, s) {
43
54
  ]);
44
55
  }
45
56
  //#endregion
46
- export { o as useSearch };
57
+ export { l as useSearch };
@@ -1,4 +1,4 @@
1
- import { SearchExecutionError, SearchOptions, SearchResult, SearchSourceLike, SearchState } from '../packages/rxdb-plugin-search/src/index.ts';
1
+ import { SearchExecutionError, SearchOptions, SearchResult, SearchSourceLike, SearchState } from '@aiao/rxdb-plugin-search';
2
2
  /**
3
3
  * React 集成 - rxdb-plugin-search
4
4
  *
@@ -1 +1 @@
1
- {"version":3,"file":"use-search.d.ts","sourceRoot":"","sources":["../src/use-search.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EACV,oBAAoB,EAEpB,aAAa,EACb,YAAY,EACZ,gBAAgB,EAChB,WAAW,EACZ,MAAM,0BAA0B,CAAC;AAElC;;;;;;;GAOG;AAEH,YAAY,EAAE,gBAAgB,EAAE,CAAC;AAEjC;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,YAAY;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,mBAAmB;IACnB,QAAQ,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9B,WAAW;IACX,OAAO,EAAE,SAAS,YAAY,EAAE,CAAC;IACjC,WAAW;IACX,KAAK,EAAE,WAAW,CAAC;IACnB,eAAe;IACf,KAAK,EAAE,oBAAoB,GAAG,SAAS,CAAC;IACxC,cAAc;IACd,OAAO,EAAE,OAAO,CAAC;IACjB,uBAAuB;IACvB,QAAQ,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9B,sBAAsB;IACtB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,oBAAoB;IACpB,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,gBAAgB,EAAE,OAAO,CAAC,EAAE,aAAa,GAAG,eAAe,CA0D5F"}
1
+ {"version":3,"file":"use-search.d.ts","sourceRoot":"","sources":["../src/use-search.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EACV,oBAAoB,EAEpB,aAAa,EACb,YAAY,EACZ,gBAAgB,EAChB,WAAW,EACZ,MAAM,0BAA0B,CAAC;AAElC;;;;;;;GAOG;AAEH,YAAY,EAAE,gBAAgB,EAAE,CAAC;AAEjC;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,YAAY;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,mBAAmB;IACnB,QAAQ,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9B,WAAW;IACX,OAAO,EAAE,SAAS,YAAY,EAAE,CAAC;IACjC,WAAW;IACX,KAAK,EAAE,WAAW,CAAC;IACnB,eAAe;IACf,KAAK,EAAE,oBAAoB,GAAG,SAAS,CAAC;IACxC,cAAc;IACd,OAAO,EAAE,OAAO,CAAC;IACjB,uBAAuB;IACvB,QAAQ,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9B,sBAAsB;IACtB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,oBAAoB;IACpB,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AA0BD;;;;;;;;;;;;GAYG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,gBAAgB,EAAE,OAAO,CAAC,EAAE,aAAa,GAAG,eAAe,CAgD5F"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiao/rxdb-plugin-search-react",
3
- "version": "0.0.20",
3
+ "version": "0.0.22",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -15,13 +15,13 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@aiao/rxdb-plugin-search": "0.0.20"
18
+ "@aiao/rxdb-plugin-search": "0.0.22"
19
19
  },
20
20
  "peerDependencies": {
21
21
  "@aiao/rxdb": "*",
22
- "react": ">=18.0.0",
22
+ "react": "^19.2.7",
23
23
  "rxjs": "^7.8.2",
24
- "@aiao/rxdb-plugin-search": "0.0.20"
24
+ "@aiao/rxdb-plugin-search": "0.0.22"
25
25
  },
26
26
  "publishConfig": {
27
27
  "access": "public"
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import type { SearchExecutionError, SearchHandle, SearchOptions, SearchResult, SearchState } from './index.js';
4
+
5
+ type PublicSearchApi = {
6
+ error: SearchExecutionError | undefined;
7
+ handle: SearchHandle;
8
+ options: SearchOptions;
9
+ result: SearchResult;
10
+ state: SearchState;
11
+ };
12
+
13
+ describe('React public search API', () => {
14
+ it('exposes the core search types from the package entrypoint', () => {
15
+ const contract: PublicSearchApi | undefined = undefined;
16
+ expect(contract).toBeUndefined();
17
+ });
18
+ });
package/src/index.ts CHANGED
@@ -8,5 +8,12 @@
8
8
  * @packageDocumentation
9
9
  */
10
10
 
11
+ export type {
12
+ SearchExecutionError,
13
+ SearchHandle,
14
+ SearchOptions,
15
+ SearchResult,
16
+ SearchState
17
+ } from '@aiao/rxdb-plugin-search';
11
18
  export { useSearch } from './use-search.js';
12
19
  export type { SearchSourceLike, UseSearchReturn } from './use-search.js';
@@ -148,4 +148,77 @@ describe('useSearch (React binding, T039)', () => {
148
148
  renderHook(() => useSearch(src, { initialQuery: 'hi' }));
149
149
  expect(src.searchSpy).toHaveBeenCalledWith('hi', { initialQuery: 'hi' });
150
150
  });
151
+
152
+ it('rebuilds the handle when source changes', () => {
153
+ vi.useFakeTimers();
154
+ const first = makeHandle();
155
+ const second = makeHandle();
156
+ const firstSource = makeSource(first.handle);
157
+ const secondSource = makeSource(second.handle);
158
+ const { result, rerender } = renderHook(({ source }) => useSearch(source), {
159
+ initialProps: { source: firstSource as SearchSourceLike }
160
+ });
161
+
162
+ rerender({ source: secondSource });
163
+ act(() => vi.runAllTimers());
164
+ act(() => result.current.setQuery('next'));
165
+
166
+ expect(first.destroyed.value).toBe(true);
167
+ expect(first.setQuery).not.toHaveBeenCalled();
168
+ expect(secondSource.searchSpy).toHaveBeenCalledTimes(1);
169
+ expect(second.setQuery).toHaveBeenCalledWith('next');
170
+ });
171
+
172
+ it('does not rebuild for a semantically identical inline options object', () => {
173
+ vi.useFakeTimers();
174
+ const h = makeHandle();
175
+ const src = makeSource(h.handle);
176
+ const { rerender } = renderHook(() => useSearch(src, { debounce: 10, collections: ['Todo'] }));
177
+
178
+ rerender();
179
+ act(() => vi.runAllTimers());
180
+
181
+ expect(src.searchSpy).toHaveBeenCalledTimes(1);
182
+ expect(h.destroyed.value).toBe(false);
183
+ });
184
+
185
+ it('routes calls to the latest handle even via a callback captured before rebuild', () => {
186
+ vi.useFakeTimers();
187
+ const first = makeHandle();
188
+ const second = makeHandle();
189
+ const firstSource = makeSource(first.handle);
190
+ const secondSource = makeSource(second.handle);
191
+ const { result, rerender } = renderHook(({ source }) => useSearch(source), {
192
+ initialProps: { source: firstSource as SearchSourceLike }
193
+ });
194
+ const staleSetQuery = result.current.setQuery;
195
+
196
+ rerender({ source: secondSource });
197
+ act(() => vi.runAllTimers());
198
+
199
+ act(() => staleSetQuery('after-rebuild'));
200
+
201
+ expect(second.setQuery).toHaveBeenCalledWith('after-rebuild');
202
+ expect(first.setQuery).not.toHaveBeenCalled();
203
+ });
204
+
205
+ it('keeps stable setQuery/clear/retry/loadMore identities across handle rebuilds', () => {
206
+ vi.useFakeTimers();
207
+ const first = makeHandle();
208
+ const second = makeHandle();
209
+ const firstSource = makeSource(first.handle);
210
+ const secondSource = makeSource(second.handle);
211
+ const { result, rerender } = renderHook(({ source }) => useSearch(source), {
212
+ initialProps: { source: firstSource as SearchSourceLike }
213
+ });
214
+ const { setQuery, clear, retry, loadMore } = result.current;
215
+
216
+ rerender({ source: secondSource });
217
+ act(() => vi.runAllTimers());
218
+
219
+ expect(result.current.setQuery).toBe(setQuery);
220
+ expect(result.current.clear).toBe(clear);
221
+ expect(result.current.retry).toBe(retry);
222
+ expect(result.current.loadMore).toBe(loadMore);
223
+ });
151
224
  });
package/src/use-search.ts CHANGED
@@ -49,6 +49,30 @@ export interface UseSearchReturn {
49
49
  retry: () => void;
50
50
  }
51
51
 
52
+ function collectionsEqual(left: readonly string[] | undefined, right: readonly string[] | undefined): boolean {
53
+ if (left === right) return true;
54
+ if (left === undefined || right === undefined || left.length !== right.length) return false;
55
+ return left.every((value, index) => value === right[index]);
56
+ }
57
+
58
+ function searchOptionsEqual(left: SearchOptions | undefined, right: SearchOptions | undefined): boolean {
59
+ if (left === right) return true;
60
+ if (left === undefined || right === undefined) return false;
61
+ return (
62
+ left.debounce === right.debounce &&
63
+ left.pageSize === right.pageSize &&
64
+ left.snippetLength === right.snippetLength &&
65
+ left.initialQuery === right.initialQuery &&
66
+ collectionsEqual(left.collections, right.collections)
67
+ );
68
+ }
69
+
70
+ function useStableSearchOptions(options: SearchOptions | undefined): SearchOptions | undefined {
71
+ const stableOptions = useRef(options);
72
+ if (!searchOptionsEqual(stableOptions.current, options)) stableOptions.current = options;
73
+ return stableOptions.current;
74
+ }
75
+
52
76
  /**
53
77
  * 将 `SearchHandle` 的响应式输出适配为 React hook。
54
78
  *
@@ -63,15 +87,10 @@ export interface UseSearchReturn {
63
87
  * @public
64
88
  */
65
89
  export function useSearch(source: SearchSourceLike, options?: SearchOptions): UseSearchReturn {
66
- const initial = options?.initialQuery ?? '';
67
- // 保持 handle 引用稳定:仅在 source 变化时重建
68
- const handleRef = useRef<SearchHandle | null>(null);
69
- const destroyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
70
- if (handleRef.current === null) {
71
- handleRef.current = source.search(initial, options);
72
- }
73
- const handle = handleRef.current;
90
+ const stableOptions = useStableSearchOptions(options);
91
+ const initial = stableOptions?.initialQuery ?? '';
74
92
 
93
+ const handleRef = useRef<SearchHandle | null>(null);
75
94
  const [query, setQueryState] = useState<string>(initial);
76
95
  const [results, setResults] = useState<readonly SearchResult[]>([]);
77
96
  const [state, setState] = useState<SearchState>('idle');
@@ -79,42 +98,37 @@ export function useSearch(source: SearchSourceLike, options?: SearchOptions): Us
79
98
  const [hasMore, setHasMore] = useState<boolean>(false);
80
99
 
81
100
  useEffect(() => {
82
- if (destroyTimerRef.current !== null) {
83
- clearTimeout(destroyTimerRef.current);
84
- destroyTimerRef.current = null;
85
- }
101
+ // handle 是命令式控制器,不参与渲染,存入 ref 而非 state:
102
+ // 避免 handle 变化触发多余的重渲染,并让 setQuery/clear/retry/loadMore
103
+ // 始终读取最新 handle,不会在 rebuild 后仍调用到已销毁的旧实例。
104
+ const newHandle = source.search(initial, stableOptions);
105
+ handleRef.current = newHandle;
106
+ setQueryState(initial);
86
107
 
87
108
  const subs = new Subscription();
88
- subs.add(handle.results$.subscribe(r => setResults(r)));
89
- subs.add(handle.state$.subscribe(s => setState(s)));
90
- subs.add(handle.error$.subscribe(e => setError(e)));
91
- subs.add(handle.hasMore$.subscribe(h => setHasMore(h)));
109
+ subs.add(newHandle.results$.subscribe(r => setResults(r)));
110
+ subs.add(newHandle.state$.subscribe(s => setState(s)));
111
+ subs.add(newHandle.error$.subscribe(e => setError(e)));
112
+ subs.add(newHandle.hasMore$.subscribe(h => setHasMore(h)));
92
113
  return () => {
93
114
  subs.unsubscribe();
94
- destroyTimerRef.current = setTimeout(() => {
95
- handle.destroy();
96
- handleRef.current = null;
97
- destroyTimerRef.current = null;
98
- }, 0);
115
+ newHandle.destroy();
116
+ if (handleRef.current === newHandle) handleRef.current = null;
99
117
  };
100
- // handle 生命周期由 ref 管理,仅挂载时订阅一次
101
- }, []);
118
+ }, [source, stableOptions, initial]);
102
119
 
103
- const setQuery = useCallback(
104
- (q: string) => {
105
- setQueryState(q);
106
- handle.setQuery(q);
107
- },
108
- [handle]
109
- );
120
+ const setQuery = useCallback((q: string) => {
121
+ setQueryState(q);
122
+ handleRef.current?.setQuery(q);
123
+ }, []);
110
124
 
111
125
  const clear = useCallback(() => {
112
126
  setQueryState('');
113
- handle.clear();
114
- }, [handle]);
127
+ handleRef.current?.clear();
128
+ }, []);
115
129
 
116
- const retry = useCallback(() => handle.retry(), [handle]);
117
- const loadMore = useCallback(() => handle.loadMore(), [handle]);
130
+ const retry = useCallback(() => handleRef.current?.retry(), []);
131
+ const loadMore = useCallback(() => handleRef.current?.loadMore() ?? Promise.resolve(), []);
118
132
 
119
133
  return useMemo(
120
134
  () => ({ query, setQuery, results, state, error, hasMore, loadMore, clear, retry }),
package/tsconfig.json CHANGED
@@ -1,4 +1,7 @@
1
1
  {
2
+ "compilerOptions": {
3
+ "ignoreDeprecations": "6.0"
4
+ },
2
5
  "extends": "../../tsconfig.base.json",
3
6
  "files": [],
4
7
  "include": [],
@@ -4,7 +4,8 @@
4
4
  "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node", "vitest"],
5
5
  "jsx": "react-jsx",
6
6
  "module": "esnext",
7
- "moduleResolution": "bundler"
7
+ "moduleResolution": "bundler",
8
+ "rootDir": "."
8
9
  },
9
10
  "extends": "../../tsconfig.base.json",
10
11
  "include": [
package/vite.config.mts CHANGED
@@ -10,7 +10,11 @@ export default defineConfig(() => ({
10
10
  cacheDir: '../../node_modules/.vite/packages/rxdb-plugin-search-react',
11
11
  plugins: [
12
12
  react(),
13
- dts({ entryRoot: 'src', tsconfigPath: path.join(import.meta.dirname, 'tsconfig.lib.json') }),
13
+ dts({
14
+ entryRoot: 'src',
15
+ pathsToAliases: false,
16
+ tsconfigPath: path.join(import.meta.dirname, 'tsconfig.lib.json')
17
+ }),
14
18
  ...(process.env.CI === 'true' && process.env.CODECOV_TOKEN ?
15
19
  [
16
20
  codecovVitePlugin({
@@ -35,7 +39,9 @@ export default defineConfig(() => ({
35
39
  fileName: 'index',
36
40
  formats: ['es' as const]
37
41
  },
38
- rollupOptions: {
42
+ rolldownOptions: {
43
+ // dts 插件生成声明文件天然比 Rolldown 原生链接阶段慢,抑制误报的 PLUGIN_TIMINGS 警告
44
+ checks: { pluginTimings: false },
39
45
  external: [/^@aiao\//, 'react', 'react-dom', 'react/jsx-runtime', 'rxjs']
40
46
  }
41
47
  },
@@ -45,7 +51,6 @@ export default defineConfig(() => ({
45
51
  globals: true,
46
52
  environment: 'jsdom',
47
53
  include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
48
- passWithNoTests: true,
49
54
  reporters: ['default'],
50
55
  coverage: {
51
56
  reportsDirectory: './test-output/vitest/coverage',