This commit is contained in:
2026-10-21 14:42:59 +08:00
commit 954236d3f9
399 changed files with 85885 additions and 0 deletions

View File

@@ -0,0 +1,204 @@
// tests/unit/renderer/components/markdown-editor-sync-cost.test.tsx
//
// 守 r247「每次击键造两份全文字符串」的修复契约 ——
// MarkdownEditor 的「value → view 同步」这一步不得再把整篇正文
// (doc.toString())重新构一遍。
//
// 背景(用户诉求「使用流畅,不要有卡顿」):
// 旧实现在**每次击键**做两次全文字符串构建:
// ① updateListener:`onChangeRef.current(u.state.doc.toString())`
// —— 必需(要把新正文交给上层),O(全文长);
// ② effect [value]:`view.state.doc.toString() !== value`
// —— 又造一份同样的全文字符串,只为了回答「doc 是不是已经等于
// value?」;而答案几乎恒为「相等」(value 就是 ① 刚发上去的那份)
// ⇒ 整次 O(n) 构建 + 比较纯属浪费。
// 10 万字笔记 = 每次击键多造一个 10 万字符的字符串,连续输入时
// GC 压力明显(整串不可增量复用)。
//
// 修法:lastEmittedRef 记「本编辑器最后一次自己发出去的正文」;
// value === lastEmitted ⇒ doc 里已经是它 ⇒ O(1) 短路,连 toString 都不调。
//
// 本测锁两个可观测量:
// A. 击键 N 次 ⇒ doc.toString() 调用次数 = 1(mount 时的初始比对)+ N(每次
// 击键 listener 那一次)—— **effect 那一步必须是 0 次**;
// B. 外部改 value(切笔记 / 外部写 body)⇒ 仍必须走完整比较并 dispatch
// (短路不能把外部改动吃掉)。
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, act, waitFor } from '@testing-library/react';
import { useState } from 'react';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
// ── 假 CodeMirror ─────────────────────────────────────────────────────────
// 够用即可:只实现本组件用到的那几个 API,并把 updateListener 的回调
// 暴露出来,让测试能手动「敲一次键」。
const cm = vi.hoisted(() => {
const api = {
doc: '',
toStringCalls: 0,
dispatches: [] as Array<unknown>,
listeners: [] as Array<(u: unknown) => void>,
view: null as null | { state: unknown; dispatch: (tr: unknown) => void },
// 撤销栈深度(假 CodeMirror 专用)—— 供 undoDepth/redoDepth 回答
undoDepth: 0,
redoDepth: 0,
makeDoc() {
return {
get length() {
return api.doc.length;
},
toString() {
api.toStringCalls += 1;
return api.doc;
},
};
},
reset() {
api.doc = '';
api.toStringCalls = 0;
api.dispatches = [];
api.listeners = [];
api.view = null;
api.undoDepth = 0;
api.redoDepth = 0;
},
};
return api;
});
vi.mock('@codemirror/state', () => ({
EditorState: {
create({ doc, extensions }: { doc: string; extensions: unknown[] }) {
cm.doc = doc;
cm.listeners = [];
for (const ext of extensions) {
const e = ext as { __kind?: string; __fn?: (u: unknown) => void };
if (e && e.__kind === 'updateListener' && typeof e.__fn === 'function') {
cm.listeners.push(e.__fn);
}
}
return { doc: cm.makeDoc(), extensions };
},
},
// 撤销栈相关:外部灌入的正文必须带 addToHistory:false(不进撤销栈)
Transaction: {
addToHistory: { of: (v: boolean) => ({ __kind: 'addToHistory', value: v }) },
},
}));
vi.mock('@codemirror/view', () => {
const facet = (kind: string) => ({
of: (v: unknown) => ({ __kind: kind, __fn: v }),
});
class EditorView {
state: unknown;
parent: unknown;
constructor(opts: { state: unknown; parent: unknown }) {
this.state = opts.state;
this.parent = opts.parent;
cm.view = this as never;
}
dispatch(tr: { changes?: { insert?: string } }) {
cm.dispatches.push(tr);
if (tr && tr.changes) {
cm.doc = tr.changes.insert ?? cm.doc;
this.state = { doc: cm.makeDoc() };
for (const l of cm.listeners) l({ docChanged: true, state: this.state });
}
}
destroy() {
/* noop */
}
static theme() {
return { __kind: 'theme' };
}
static lineWrapping = { __kind: 'lineWrapping' };
static contentAttributes = facet('contentAttributes');
static updateListener = facet('updateListener');
}
return {
EditorView,
keymap: { of: () => ({ __kind: 'keymap' }) },
drawSelection: () => ({ __kind: 'drawSelection' }),
dropCursor: () => ({ __kind: 'dropCursor' }),
rectangularSelection: () => ({ __kind: 'rectangularSelection' }),
};
});
vi.mock('@codemirror/commands', () => ({
defaultKeymap: [],
historyKeymap: [],
indentWithTab: { __kind: 'indentWithTab' },
history: () => ({ __kind: 'history' }),
// 撤销栈深度 —— 编辑器每次状态变化都会问一次(按钮 enabled 态的来源)
undoDepth: () => cm.undoDepth,
redoDepth: () => cm.redoDepth,
undo: () => true,
redo: () => true,
}));
vi.mock('@codemirror/lang-markdown', () => ({
markdown: () => ({ __kind: 'markdown' }),
markdownLanguage: { __kind: 'markdownLanguage' },
}));
import { MarkdownEditor } from '../../../../src/renderer/src/components/editor/MarkdownEditor';
/** 模拟一次击键:doc 变了 ⇒ CodeMirror 触发 updateListener。 */
function type(text: string): void {
cm.doc = text;
for (const l of cm.listeners) l({ docChanged: true, state: cm.view?.state });
}
function Harness({ initial }: { initial: string }) {
const [v, setV] = useState(initial);
return (
<>
<MarkdownEditor value={v} onChange={setV} />
<output data-testid="out">{v}</output>
</>
);
}
/** 受控 harness:同一个编辑器实例上换 value(模拟外部改 body / 换笔记)。 */
function Controlled({ value }: { value: string }) {
return <MarkdownEditor value={value} onChange={() => {}} />;
}
describe('perf r247:MarkdownEditor 击键同步成本', () => {
beforeEach(() => {
cm.reset();
});
it('连击 N 次 ⇒ doc.toString() 只在「mount 比对 + 每次击键的 listener」被调,effect 那一步为 0', async () => {
const { getByTestId } = render(<Harness initial="hello" />);
await waitFor(() => expect(cm.view).not.toBeNull());
// mount:创建 view 后比对一次(initialDoc === value ⇒ 不 dispatch)
expect(cm.toStringCalls, 'mount 只应比对一次').toBe(1);
for (const text of ['hello!', 'hello!!', 'hello!!!']) {
act(() => {
type(text);
});
expect(getByTestId('out').textContent, '击键内容必须真的到了上层').toBe(text);
}
// 3 次击键 ⇒ listener 3 次 + mount 1 次 = 4
// 旧实现 = 4 + 3(effect 每次再构一份全文)= 7
expect(cm.toStringCalls, '同步 effect 不得再构全文').toBe(4);
});
it('外部改 value ⇒ 仍走完整比较并 dispatch(短路不能吃掉外部改动)', async () => {
const { rerender } = render(<Controlled value="hello" />);
await waitFor(() => expect(cm.view).not.toBeNull());
rerender(<Controlled value="外部写进来的正文" />);
await waitFor(() => expect(cm.dispatches.length).toBeGreaterThan(0));
const tr = cm.dispatches[0] as { changes: { insert: string } };
expect(tr.changes.insert, '外部正文必须被 dispatch 进编辑器').toBe('外部写进来的正文');
expect(cm.doc).toBe('外部写进来的正文');
});
});