// tests/unit/renderer/store/struct-share.test.tsx // // 守 r258「列表刷新不许把整表换成新对象」契约 —— // IPC 快照的**结构共享**:内容没变的条目沿用上一轮的对象引用。 // // ── 被守卫的缺陷(假绿优化)───────────────────────────────────────── // 两个列表 store(notes / literature)的 load() 都把 IPC 回来的快照**整 // 表替换**进 state。IPC 走 structured clone ⇒ 每次回来的都是**全新对 // 象**(内容一模一样也是新引用)。于是: // // ① store.notes / store.items 每轮换引用; // ② 组件用 `useShallow(s => s.notes)` 订阅 —— useShallow 的浅比是 // **逐元素 Object.is** ⇒ 元素全是新引用 ⇒ 浅比恒为「不等」⇒ // **useShallow 从来没生效过**(两处组件注释里写的「浅比较元素相同 // 时不重渲」是**假绿**:元素引用从不相同); // ③ 每次 NOTES_CHANGED / LITERATURE_CHANGED(自动保存、外部改盘、网 // 盘同步落盘、另一个窗口写盘)⇒ 整个可见列表 + 每一行都重渲。 // // ── 本测锁五件事 ─────────────────────────────────────────────────── // A. store 层:内容完全相同的二次 load ⇒ `notes` **数组引用都不变** // (不只是元素,连数组都是旧的)—— 这是 useShallow 能 bail out 的前提; // B. store 层:一条笔记变了 ⇒ 变的那条是**新对象**,其余条目**沿用旧 // 引用**(行级 React.memo 只重渲那一条); // C. store 层:顺序变了 ⇒ 数组是新数组,但**元素全部沿用旧引用**; // D. **组件层(本轮的性能契约本体)**:订阅 `useShallow(s => s.notes)` // 的组件,在一次「内容完全相同」的 load() 之后**渲染次数不变** // —— 修前每次广播都重渲; // E. 文献域同款(items 引用不变)。 // // 文献域与笔记域的 useShallow 订阅点: // NotesList.tsx:94 / LiteratureList.tsx:104。 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { render, act } from '@testing-library/react'; import { useShallow } from 'zustand/react/shallow'; vi.mock('../../../../src/renderer/src/store/workspace', () => ({ useWorkspaceStore: { getState: () => ({ pendingFocusNote: { hasPending: false, payload: null }, consumeFocusNoteRequest: () => {}, }), }, })); vi.mock('../../../../src/renderer/src/lib/toast', () => ({ useToast: { getState: () => ({ show: () => {} }), }, })); import { useNotesStore } from '../../../../src/renderer/src/store/notes'; import { useLiteratureStore } from '../../../../src/renderer/src/store/literature'; import type { LiteratureResource, StandaloneNoteSummary } from '../../../../src/shared/types'; function summary(id: string, over: Partial = {}): StandaloneNoteSummary { return { id, relPath: `${id}.md`, filename: `${id}.md`, title: null, tags: null, mtime: 1000, ...over, }; } function litItem(id: string, over: Partial = {}): LiteratureResource { return { id: id as LiteratureResource['id'], kind: 'literature', title: id, mtime: 1000, size: 10, indexedAt: 0, absPath: `C:/lit/${id}.pdf`, relPath: `${id}.pdf`, rootId: null, extractStatus: 'filename-only', ...over, } as LiteratureResource; } interface MockApi { listNotes: ReturnType; listLiterature: ReturnType; onNotesChanged: ReturnType; onLiteratureChanged: ReturnType; } function setupMockApi(): MockApi { const api: MockApi = { listNotes: vi.fn().mockResolvedValue({ ok: true, data: [] }), listLiterature: vi.fn().mockResolvedValue({ ok: true, data: [] }), onNotesChanged: vi.fn().mockReturnValue(() => {}), onLiteratureChanged: vi.fn().mockReturnValue(() => {}), }; // 注意:只**挂** api,不要整体替换 window —— jsdom 环境下 window 就是 // document 的宿主,整体替换会把 react-dom 依赖的 window/document 一起 // 抹掉(实测报 "Right-hand side of 'instanceof' is not an object")。 const w = globalThis as unknown as { window: { api?: MockApi } }; w.window.api = api; useNotesStore.setState({ notes: [], selectedIds: new Set(), selectedId: null, activeNoteFull: null, anchorId: null, clipboard: null, lastError: null, displayOrderById: {}, }); useLiteratureStore.setState({ items: [], selectedId: null, loading: false, lastError: null, selectedIds: new Set(), anchorId: null, clipboard: null, displayOrderById: {}, }); return api; } describe('列表快照的结构共享(r258)', () => { let api: MockApi; beforeEach(() => { api = setupMockApi(); }); afterEach(() => { vi.restoreAllMocks(); }); it('A 内容完全相同的二次 load ⇒ notes 数组引用都不变', async () => { api.listNotes.mockResolvedValue({ ok: true, data: [summary('a'), summary('b')] }); await useNotesStore.getState().load(); const first = useNotesStore.getState().notes; expect(first).toHaveLength(2); // IPC 再发一次「一模一样」的内容(structured clone ⇒ 全是新对象) api.listNotes.mockResolvedValue({ ok: true, data: [summary('a'), summary('b')] }); await useNotesStore.getState().load(); const second = useNotesStore.getState().notes; expect(second, '内容没变却换数组引用 ⇒ useShallow 的浅比永远判「不等」').toBe(first); expect(second[0]).toBe(first[0]); expect(second[1]).toBe(first[1]); }); it('B 一条笔记变了 ⇒ 变的那条换新对象,其余沿用旧引用', async () => { api.listNotes.mockResolvedValue({ ok: true, data: [summary('a'), summary('b')] }); await useNotesStore.getState().load(); const first = useNotesStore.getState().notes; api.listNotes.mockResolvedValue({ ok: true, data: [summary('a', { mtime: 2000, title: 'A' }), summary('b')], }); await useNotesStore.getState().load(); const second = useNotesStore.getState().notes; expect(second, '有一条变了 ⇒ 数组必须换新引用').not.toBe(first); expect(second[0], '变的那条必须是新对象').not.toBe(first[0]); expect(second[1], '没变的那条必须沿用旧引用(行级 memo 才不会白失效)').toBe(first[1]); }); it('C 顺序变了 ⇒ 新数组,但元素全部沿用旧引用', async () => { api.listNotes.mockResolvedValue({ ok: true, data: [summary('a'), summary('b')] }); await useNotesStore.getState().load(); const first = useNotesStore.getState().notes; api.listNotes.mockResolvedValue({ ok: true, data: [summary('b'), summary('a')] }); await useNotesStore.getState().load(); const second = useNotesStore.getState().notes; expect(second).not.toBe(first); expect(second[0]).toBe(first[1]); expect(second[1]).toBe(first[0]); }); it('D 组件层:内容相同的 load ⇒ useShallow 订阅者不重渲(本轮的性能契约)', async () => { api.listNotes.mockResolvedValue({ ok: true, data: [summary('a'), summary('b')] }); let renders = 0; function Probe(): JSX.Element { const notes = useNotesStore(useShallow((s) => s.notes)); renders += 1; return
{notes.length}
; } render(); await act(async () => { await useNotesStore.getState().load(); }); const afterFirst = renders; expect(afterFirst).toBeGreaterThan(0); // 第二次:IPC 内容一模一样(新对象)—— 组件**不该**重渲 api.listNotes.mockResolvedValue({ ok: true, data: [summary('a'), summary('b')] }); await act(async () => { await useNotesStore.getState().load(); }); expect( renders, '内容一个字没变却整表重渲 —— 这就是 useShallow 一直没生效的代价', ).toBe(afterFirst); }); it('F 带 tags 的笔记:tags 内容相同也算「没变」(数组要逐元素比)', async () => { api.listNotes.mockResolvedValue({ ok: true, data: [summary('a', { tags: ['x', 'y'] })] }); await useNotesStore.getState().load(); const first = useNotesStore.getState().notes; // tags 经 IPC clone 后是**新数组**,但内容一样 —— 必须判为「没变」 api.listNotes.mockResolvedValue({ ok: true, data: [summary('a', { tags: ['x', 'y'] })] }); await useNotesStore.getState().load(); const second = useNotesStore.getState().notes; expect(second, 'tags 数组是新对象但内容一样 ⇒ 必须判为「没变」').toBe(first); }); it('E 文献域同款:内容相同的 load ⇒ items 数组引用不变', async () => { api.listLiterature.mockResolvedValue({ ok: true, data: [litItem('x'), litItem('y')] }); await useLiteratureStore.getState().load({ force: true }); const first = useLiteratureStore.getState().items; expect(first).toHaveLength(2); api.listLiterature.mockResolvedValue({ ok: true, data: [litItem('x'), litItem('y')] }); await useLiteratureStore.getState().load({ force: true }); const second = useLiteratureStore.getState().items; expect(second).toBe(first); expect(second[0]).toBe(first[0]); }); });