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

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,3 @@
1
+ declare const _default: import("vite").UserConfigFnObject;
2
+ export default _default;
3
+ //# sourceMappingURL=vite.config.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vite.config.d.mts","sourceRoot":"","sources":["../../vite.config.mts"],"names":[],"mappings":";AAOA,wBA+CI"}
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.21",
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.21"
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.21"
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',