mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-11-01 15:40:43 +00:00
fix(runtime): 修复 useDsl 换页后更新失效与重复替换
- 事件处理改为读取最新页面配置(ref),换页后不再更新到旧页面 - react 版事件监听移入 useEffect 并依赖 app,避免监听泄漏 - 按节点对象去重并批量 replaceChildNodes,同一次批量更新的多份相同节点只替换一次 - 忽略非当前页面的 update-data
This commit is contained in:
parent
35ed2cc0d0
commit
0c1d103cd0
@ -1,45 +1,95 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
import type TMagicApp from '@tmagic/core';
|
||||
import type { ChangeEvent, MNode } from '@tmagic/core';
|
||||
import { isPage, replaceChildNode } from '@tmagic/core';
|
||||
import type { ChangeEvent, Id, MNode } from '@tmagic/core';
|
||||
import { isPage, replaceChildNodes } from '@tmagic/core';
|
||||
|
||||
export const useDsl = (app: TMagicApp | undefined) => {
|
||||
const [pageConfig, setPageConfig] = useState(app?.page?.data);
|
||||
|
||||
app?.on('page-change', () => {
|
||||
setPageConfig(app.page?.data);
|
||||
});
|
||||
// 始终指向最新的页面配置,事件处理函数只注册一次,不能直接使用闭包里的 pageConfig(换页后会是旧页面)
|
||||
const pageConfigRef = useRef(pageConfig);
|
||||
pageConfigRef.current = pageConfig;
|
||||
|
||||
const updateDataHandler = (nodes: MNode[], sourceId: string, event: ChangeEvent) => {
|
||||
let config = pageConfig;
|
||||
nodes.forEach((node) => {
|
||||
if (isPage(node)) {
|
||||
config = node;
|
||||
} else {
|
||||
config && replaceChildNode(node, [config]);
|
||||
}
|
||||
});
|
||||
|
||||
setPageConfig(cloneDeep(config));
|
||||
|
||||
setTimeout(() => {
|
||||
app?.emit('replaced-node', {
|
||||
...event,
|
||||
nodes,
|
||||
sourceId,
|
||||
});
|
||||
}, 0);
|
||||
};
|
||||
// 同一次 batch 的多个 update-data 携带的是同一份已编译节点,重复替换内容相同却要再付一次全树遍历和整页 cloneDeep,
|
||||
// 因此按节点对象去重:同一个节点对象只替换一次(非 batch 场景下每次编译都是新对象,不受影响)
|
||||
const appliedNodesRef = useRef<WeakSet<MNode> | null>(null);
|
||||
if (!appliedNodesRef.current) {
|
||||
appliedNodesRef.current = new WeakSet();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
app?.dataSourceManager?.on('update-data', updateDataHandler);
|
||||
if (!app) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageChangeHandler = () => {
|
||||
appliedNodesRef.current = new WeakSet();
|
||||
pageConfigRef.current = app.page?.data;
|
||||
setPageConfig(app.page?.data);
|
||||
};
|
||||
|
||||
app.on('page-change', pageChangeHandler);
|
||||
|
||||
return () => {
|
||||
app?.dataSourceManager?.off('update-data', updateDataHandler);
|
||||
app.off('page-change', pageChangeHandler);
|
||||
};
|
||||
}, []);
|
||||
}, [app]);
|
||||
|
||||
useEffect(() => {
|
||||
const dataSourceManager = app?.dataSourceManager;
|
||||
if (!app || !dataSourceManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateDataHandler = (nodes: MNode[], sourceId: string, event: ChangeEvent, pageId?: Id) => {
|
||||
// 不是当前页面的更新(如页面片、其他页面)不处理
|
||||
if (!nodes.length || (pageId !== undefined && app.page?.data.id !== pageId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const appliedNodes = appliedNodesRef.current!;
|
||||
|
||||
let config = pageConfigRef.current;
|
||||
let pending: MNode[] = [];
|
||||
nodes.forEach((node) => {
|
||||
if (isPage(node)) {
|
||||
config && replaceChildNodes(pending, [config]);
|
||||
pending = [];
|
||||
config = node;
|
||||
appliedNodesRef.current = new WeakSet();
|
||||
} else if (!appliedNodes.has(node)) {
|
||||
pending.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
if (pending.length) {
|
||||
for (const node of pending) {
|
||||
appliedNodes.add(node);
|
||||
}
|
||||
config && replaceChildNodes(pending, [config]);
|
||||
|
||||
const newConfig = cloneDeep(config);
|
||||
pageConfigRef.current = newConfig;
|
||||
setPageConfig(newConfig);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
app.emit('replaced-node', {
|
||||
...event,
|
||||
nodes,
|
||||
sourceId,
|
||||
});
|
||||
}, 0);
|
||||
};
|
||||
|
||||
dataSourceManager.on('update-data', updateDataHandler);
|
||||
|
||||
return () => {
|
||||
dataSourceManager.off('update-data', updateDataHandler);
|
||||
};
|
||||
}, [app]);
|
||||
|
||||
return { pageConfig };
|
||||
};
|
||||
|
||||
208
runtime/react-runtime-help/tests/use-dsl.test.ts
Normal file
208
runtime/react-runtime-help/tests/use-dsl.test.ts
Normal file
@ -0,0 +1,208 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import TMagicApp, { NodeType } from '@tmagic/core';
|
||||
|
||||
import { useDsl } from '../src/hooks/use-dsl';
|
||||
|
||||
/**
|
||||
* 最小化的 react hooks 运行时(react-dom 没有安装在该包中):
|
||||
* 支持 useState / useRef / useEffect(含 deps 与 cleanup),渲染由测试手动触发
|
||||
*/
|
||||
const runtime = vi.hoisted(() => {
|
||||
interface Instance {
|
||||
hooks: any[];
|
||||
index: number;
|
||||
effects: { deps?: any[]; cleanup?: () => void; fn: () => void | (() => void) }[];
|
||||
pending: (() => void)[];
|
||||
}
|
||||
const state: { current: Instance | null } = { current: null };
|
||||
return { state };
|
||||
});
|
||||
|
||||
vi.mock('react', () => ({
|
||||
useState: (init: any) => {
|
||||
const inst = runtime.state.current!;
|
||||
const i = inst.index;
|
||||
inst.index += 1;
|
||||
if (!(i in inst.hooks)) inst.hooks[i] = init;
|
||||
return [inst.hooks[i], (v: any) => (inst.hooks[i] = v)];
|
||||
},
|
||||
useRef: (init: any) => {
|
||||
const inst = runtime.state.current!;
|
||||
const i = inst.index;
|
||||
inst.index += 1;
|
||||
if (!(i in inst.hooks)) inst.hooks[i] = { current: init };
|
||||
return inst.hooks[i];
|
||||
},
|
||||
useEffect: (fn: () => void | (() => void), deps?: any[]) => {
|
||||
const inst = runtime.state.current!;
|
||||
const i = inst.index;
|
||||
inst.index += 1;
|
||||
const prev = inst.effects[i];
|
||||
const changed = !prev || !deps || !prev.deps || deps.some((d, k) => d !== prev.deps![k]);
|
||||
if (changed) {
|
||||
inst.pending.push(() => {
|
||||
prev?.cleanup?.();
|
||||
const cleanup = fn();
|
||||
inst.effects[i] = { deps, fn, cleanup: typeof cleanup === 'function' ? cleanup : undefined };
|
||||
});
|
||||
} else {
|
||||
inst.effects[i] = prev;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const mountHook = (app: TMagicApp | undefined) => {
|
||||
const inst = { hooks: [] as any[], index: 0, effects: [] as any[], pending: [] as (() => void)[] };
|
||||
let result: ReturnType<typeof useDsl>;
|
||||
const render = () => {
|
||||
inst.index = 0;
|
||||
runtime.state.current = inst;
|
||||
result = useDsl(app);
|
||||
runtime.state.current = null;
|
||||
inst.pending.splice(0).forEach((run) => run());
|
||||
return result;
|
||||
};
|
||||
const unmount = () => inst.effects.forEach((e) => e?.cleanup?.());
|
||||
return {
|
||||
render,
|
||||
unmount,
|
||||
get result() {
|
||||
return result;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createApp = () =>
|
||||
new TMagicApp({
|
||||
platform: 'mobile',
|
||||
config: {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app',
|
||||
items: [
|
||||
{ type: NodeType.PAGE, id: 'p1', items: [{ id: 'n1', type: 'text', text: 'p1 ${ds.name}' }] },
|
||||
{ type: NodeType.PAGE, id: 'p2', items: [{ id: 'n2', type: 'text', text: 'p2 ${ds.name}' }] },
|
||||
],
|
||||
dataSourceDeps: { ds: { n1: { name: 'n1', keys: ['text'] }, n2: { name: 'n2', keys: ['text'] } } },
|
||||
dataSources: [{ id: 'ds', type: 'base', fields: [{ name: 'name', defaultValue: 'x' }], methods: [], events: [] }],
|
||||
} as any,
|
||||
});
|
||||
|
||||
describe('react useDsl', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('多次重渲染只保留一个 page-change / update-data 监听,卸载后清理', () => {
|
||||
const app = createApp();
|
||||
const hook = mountHook(app);
|
||||
for (let i = 0; i < 5; i++) hook.render();
|
||||
|
||||
expect(app.listenerCount('page-change')).toBe(1);
|
||||
expect(app.dataSourceManager!.listenerCount('update-data')).toBe(1);
|
||||
|
||||
hook.unmount();
|
||||
expect(app.listenerCount('page-change')).toBe(0);
|
||||
expect(app.dataSourceManager!.listenerCount('update-data')).toBe(0);
|
||||
});
|
||||
|
||||
test('换页后数据源更新,处理的是当前页面而不是首次渲染时的页面', () => {
|
||||
const app = createApp();
|
||||
const hook = mountHook(app);
|
||||
hook.render();
|
||||
expect(hook.result.pageConfig?.id).toBe('p1');
|
||||
|
||||
app.setPage('p2');
|
||||
expect(hook.render().pageConfig?.id).toBe('p2');
|
||||
|
||||
app.dataSourceManager!.get('ds')!.setData('NEW', 'name');
|
||||
const { pageConfig } = hook.render();
|
||||
|
||||
expect(pageConfig?.id).toBe('p2');
|
||||
expect((pageConfig as any).items[0].text).toBe('p2 NEW');
|
||||
});
|
||||
|
||||
test('连续多次更新都基于最新配置', () => {
|
||||
const app = createApp();
|
||||
const hook = mountHook(app);
|
||||
hook.render();
|
||||
|
||||
const ds = app.dataSourceManager!.get('ds')!;
|
||||
ds.setData('A', 'name');
|
||||
ds.setData('B', 'name');
|
||||
expect((hook.render().pageConfig as any).items[0].text).toBe('p1 B');
|
||||
});
|
||||
|
||||
test('非当前页面(页面片等)的 update-data 被忽略', () => {
|
||||
const app = createApp();
|
||||
const hook = mountHook(app);
|
||||
const before = hook.render().pageConfig;
|
||||
const replaced = vi.fn();
|
||||
app.on('replaced-node', replaced);
|
||||
|
||||
app.dataSourceManager!.emit(
|
||||
'update-data',
|
||||
[{ id: 'n1', type: 'text', text: 'OTHER' }],
|
||||
'ds',
|
||||
{ updateData: 1 },
|
||||
'p2',
|
||||
);
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(hook.render().pageConfig).toBe(before);
|
||||
expect(replaced).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('更新后异步发出 replaced-node', () => {
|
||||
const app = createApp();
|
||||
const hook = mountHook(app);
|
||||
hook.render();
|
||||
const replaced = vi.fn();
|
||||
app.on('replaced-node', replaced);
|
||||
|
||||
app.dataSourceManager!.get('ds')!.setData('Z', 'name');
|
||||
expect(replaced).not.toHaveBeenCalled();
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(replaced).toHaveBeenCalledTimes(1);
|
||||
expect(replaced.mock.calls[0][0].sourceId).toBe('ds');
|
||||
expect(replaced.mock.calls[0][0].nodes[0].id).toBe('n1');
|
||||
});
|
||||
|
||||
test('同一份已编译节点的多次 update-data 只替换一次,但 replaced-node 每次都发', () => {
|
||||
const app = createApp();
|
||||
const hook = mountHook(app);
|
||||
hook.render();
|
||||
const replaced = vi.fn();
|
||||
app.on('replaced-node', replaced);
|
||||
|
||||
const node: any = { id: 'n1', type: 'text', text: 'NEW' };
|
||||
|
||||
app.dataSourceManager!.emit('update-data', [node], 'ds', { updateData: 'a' }, 'p1');
|
||||
const afterFirst = hook.render().pageConfig;
|
||||
|
||||
app.dataSourceManager!.emit('update-data', [node], 'ds', { updateData: 'b' }, 'p1');
|
||||
const afterSecond = hook.render().pageConfig;
|
||||
|
||||
// 第二个事件没有新节点需要替换,不再 cloneDeep / setPageConfig
|
||||
expect(afterSecond).toBe(afterFirst);
|
||||
expect((afterSecond as any).items[0].text).toBe('NEW');
|
||||
|
||||
vi.runAllTimers();
|
||||
expect(replaced).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('app 或 dataSourceManager 不存在时不报错', () => {
|
||||
expect(() => mountHook(undefined).render()).not.toThrow();
|
||||
|
||||
const app = new TMagicApp({});
|
||||
const hook = mountHook(app);
|
||||
expect(() => hook.render()).not.toThrow();
|
||||
expect(app.listenerCount('page-change')).toBe(1);
|
||||
hook.unmount();
|
||||
});
|
||||
});
|
||||
@ -2,7 +2,7 @@ import { inject, nextTick, onBeforeUnmount, reactive, ref } from 'vue';
|
||||
|
||||
import type TMagicApp from '@tmagic/core';
|
||||
import type { ChangeEvent, Id, MNode } from '@tmagic/core';
|
||||
import { isPage, isPageFragment, replaceChildNode } from '@tmagic/core';
|
||||
import { isPage, isPageFragment, replaceChildNodes } from '@tmagic/core';
|
||||
|
||||
export const useDsl = (app = inject<TMagicApp>('app'), pageFragmentContainerId?: Id) => {
|
||||
if (!app) {
|
||||
@ -11,10 +11,29 @@ export const useDsl = (app = inject<TMagicApp>('app'), pageFragmentContainerId?:
|
||||
|
||||
const pageFragment = pageFragmentContainerId ? app.pageFragments.get(pageFragmentContainerId) : null;
|
||||
|
||||
// 同一次 batch 的多个 update-data 携带的是同一份已编译节点,重复替换内容相同却要再付一次全树遍历,
|
||||
// 因此按节点对象去重:同一个节点对象只替换一次(非 batch 场景下每次编译都是新对象,不受影响)
|
||||
let appliedNodes: WeakSet<MNode> = new WeakSet();
|
||||
|
||||
const applyNodes = (nodes: MNode[], config: MNode) => {
|
||||
const pending = nodes.filter((node) => !appliedNodes.has(node));
|
||||
|
||||
if (!pending.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const node of pending) {
|
||||
appliedNodes.add(node);
|
||||
}
|
||||
|
||||
replaceChildNodes(pending, [config]);
|
||||
};
|
||||
|
||||
const pageConfig = ref<MNode | undefined>(pageFragmentContainerId ? pageFragment?.data : app.page?.data);
|
||||
|
||||
if (pageFragmentContainerId) {
|
||||
const setPageConfig = () => {
|
||||
appliedNodes = new WeakSet();
|
||||
pageConfig.value = pageFragment?.data;
|
||||
};
|
||||
app.on('dsl-change', setPageConfig);
|
||||
@ -24,6 +43,7 @@ export const useDsl = (app = inject<TMagicApp>('app'), pageFragmentContainerId?:
|
||||
});
|
||||
} else {
|
||||
const setPageConfig = () => {
|
||||
appliedNodes = new WeakSet();
|
||||
pageConfig.value = app.page?.data;
|
||||
};
|
||||
app.on('page-change', setPageConfig);
|
||||
@ -42,16 +62,25 @@ export const useDsl = (app = inject<TMagicApp>('app'), pageFragmentContainerId?:
|
||||
return;
|
||||
}
|
||||
|
||||
// 批量替换,避免每个节点都做一次全树搜索
|
||||
let pending: MNode[] = [];
|
||||
for (const node of nodes) {
|
||||
if (
|
||||
(isPage(node) && !pageFragmentContainerId && node.id === pageId) ||
|
||||
(isPageFragment(node) && pageFragmentContainerId)
|
||||
) {
|
||||
applyNodes(pending, pageConfig.value as MNode);
|
||||
pending = [];
|
||||
pageConfig.value = node;
|
||||
appliedNodes = new WeakSet();
|
||||
} else {
|
||||
replaceChildNode(reactive(node), [pageConfig.value as MNode]);
|
||||
const newNode = reactive(node) as MNode;
|
||||
if (!appliedNodes.has(newNode)) {
|
||||
pending.push(newNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
applyNodes(pending, pageConfig.value as MNode);
|
||||
|
||||
nextTick(() => {
|
||||
app.emit('replaced-node', { nodes, sourceId, ...changeEvent });
|
||||
|
||||
286
runtime/vue-runtime-help/tests/iterator-e2e.test.ts
Normal file
286
runtime/vue-runtime-help/tests/iterator-e2e.test.ts
Normal file
@ -0,0 +1,286 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick, provide } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import TMagicApp, { DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX, NodeType } from '@tmagic/core';
|
||||
|
||||
import IteratorContainer from '../../../vue-components/iterator-container/src/IteratorContainer.vue';
|
||||
import { useDsl } from '../src';
|
||||
|
||||
/** 叶子组件:接收绑定到数据源的“兄弟字段” matrix 作为 prop,用于验证子孙组件的更新 */
|
||||
const cellRenders = { count: 0 };
|
||||
const cellComp = defineComponent({
|
||||
props: { matrix: { type: Array, default: () => [] } },
|
||||
setup(props) {
|
||||
return () => {
|
||||
cellRenders.count += 1;
|
||||
return h('i', { class: 'cell' }, String((props.matrix[0] as any)?.v));
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const textComp = defineComponent({
|
||||
props: { config: { type: Object, required: true } },
|
||||
setup(props) {
|
||||
return () =>
|
||||
h('span', { class: 'txt' }, [
|
||||
(props.config as any).text,
|
||||
(props.config as any).cell ? h(cellComp, { matrix: (props.config as any).cell.matrix }) : null,
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
const createApp = () => {
|
||||
const app = new TMagicApp({
|
||||
platform: 'mobile',
|
||||
config: {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app',
|
||||
items: [
|
||||
{
|
||||
type: NodeType.PAGE,
|
||||
id: 'page',
|
||||
items: [
|
||||
{
|
||||
type: 'iterator-container',
|
||||
id: 'iter',
|
||||
dsField: ['ds', 'list'],
|
||||
iteratorData: [`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}ds`, 'list'],
|
||||
itemConfig: { style: {} },
|
||||
items: [
|
||||
{
|
||||
type: 'text',
|
||||
id: 'txt',
|
||||
text: '${ds.list.label}',
|
||||
cell: { isBindDataSource: true, dataSourceId: 'ds' },
|
||||
events: [{ name: 'click', actions: [] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'text', id: 'plain', text: 'total=${ds.total}' },
|
||||
],
|
||||
},
|
||||
],
|
||||
dataSourceDeps: {
|
||||
ds: { iter: { name: 'iter', keys: ['iteratorData'] }, plain: { name: 'plain', keys: ['text'] } },
|
||||
},
|
||||
dataSources: [
|
||||
{
|
||||
id: 'ds',
|
||||
type: 'base',
|
||||
fields: [
|
||||
{
|
||||
name: 'list',
|
||||
type: 'array',
|
||||
fields: [{ name: 'label' }],
|
||||
defaultValue: [{ label: 'A' }, { label: 'B' }],
|
||||
},
|
||||
{ name: 'matrix', type: 'array', fields: [{ name: 'v' }], defaultValue: [{ v: 1 }] },
|
||||
{ name: 'total', defaultValue: 2 },
|
||||
],
|
||||
methods: [],
|
||||
events: [],
|
||||
},
|
||||
],
|
||||
} as any,
|
||||
});
|
||||
app.registerComponent('iterator-container', IteratorContainer);
|
||||
app.registerComponent('text', textComp);
|
||||
const renderNodes = (items: any[]) =>
|
||||
(items || []).map((item) => h(app.resolveComponent(item.type), { key: item.id, config: item }));
|
||||
app.registerComponent(
|
||||
'container',
|
||||
defineComponent({
|
||||
props: { config: { type: Object, required: true } },
|
||||
setup(props) {
|
||||
return () => h('div', renderNodes((props.config as any).items));
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.registerComponent(
|
||||
'page',
|
||||
defineComponent({
|
||||
setup() {
|
||||
const { pageConfig } = useDsl(app);
|
||||
// 与真实运行时一致:页面组件创建后会给 page 设置 instance
|
||||
app.page!.emit('created', { config: app.page!.data });
|
||||
return () => h('div', { class: 'page' }, renderNodes(pageConfig.value?.items as any[]));
|
||||
},
|
||||
}),
|
||||
);
|
||||
return app;
|
||||
};
|
||||
|
||||
const mountApp = (app: TMagicApp) => {
|
||||
const root = defineComponent({
|
||||
setup() {
|
||||
provide('app', app);
|
||||
return () => h(app.resolveComponent('page'));
|
||||
},
|
||||
});
|
||||
return mount(root, { global: { provide: { app } } });
|
||||
};
|
||||
|
||||
const texts = (wrapper: ReturnType<typeof mountApp>) =>
|
||||
wrapper.findAll('.txt').map((el) => el.element.firstChild?.textContent);
|
||||
|
||||
describe('真实 IteratorContainer + useDsl + createDataSourceManager', () => {
|
||||
afterEach(() => {
|
||||
cellRenders.count = 0;
|
||||
});
|
||||
|
||||
test('初始渲染', async () => {
|
||||
const app = createApp();
|
||||
const wrapper = mountApp(app);
|
||||
await nextTick();
|
||||
expect(texts(wrapper)).toEqual(['A', 'B', 'total=2']);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('整体替换列表(含追加一项)后视图更新', async () => {
|
||||
const app = createApp();
|
||||
const wrapper = mountApp(app);
|
||||
await nextTick();
|
||||
|
||||
app.dataSourceManager!.get('ds')!.setData([{ label: 'A' }, { label: 'B' }, { label: 'C' }], 'list');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(texts(wrapper)).toEqual(['A', 'B', 'C', 'total=2']);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('按 path 原地修改 matrix.0.v:迭代项里的子孙组件会更新', async () => {
|
||||
const app = createApp();
|
||||
const wrapper = mountApp(app);
|
||||
await nextTick();
|
||||
expect(wrapper.findAll('.cell').map((c) => c.text())).toEqual(['1', '1']);
|
||||
|
||||
// 触发迭代容器重新编译(列表整体替换),同时 matrix 原地修改
|
||||
app.dataSourceManager!.get('ds')!.setData(9, 'matrix.0.v');
|
||||
app.dataSourceManager!.get('ds')!.setData([{ label: 'A' }, { label: 'B' }], 'list');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(wrapper.findAll('.cell').map((c) => c.text())).toEqual(['9', '9']);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('batch:多次 setData 合并成一次 update-data,视图为最终值', async () => {
|
||||
const app = createApp();
|
||||
const wrapper = mountApp(app);
|
||||
await nextTick();
|
||||
const update = vi.fn();
|
||||
app.dataSourceManager!.on('update-data', update);
|
||||
|
||||
app.dataSourceManager!.batch(
|
||||
() => {
|
||||
const ds = app.dataSourceManager!.get('ds')!;
|
||||
ds.setData(3, 'total');
|
||||
ds.setData(4, 'total');
|
||||
ds.setData([{ label: 'X' }], 'list');
|
||||
},
|
||||
{ mergeChangeEvents: true },
|
||||
);
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(texts(wrapper)).toEqual(['X', 'total=4']);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('batch 会让 replaced-node 只触发一次并只携带最后一次变更', async () => {
|
||||
const app = createApp();
|
||||
const wrapper = mountApp(app);
|
||||
await nextTick();
|
||||
const replaced = vi.fn();
|
||||
app.on('replaced-node', replaced);
|
||||
const ds = app.dataSourceManager!.get('ds')!;
|
||||
|
||||
ds.setData(1, 'total');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(replaced).toHaveBeenCalledTimes(1);
|
||||
|
||||
replaced.mockClear();
|
||||
app.dataSourceManager!.batch(
|
||||
() => {
|
||||
ds.setData(2, 'total');
|
||||
ds.setData([{ label: 'Y' }], 'list');
|
||||
},
|
||||
{ mergeChangeEvents: true },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// 合并后只发一次,changeEvent 取该数据源最后一次变更
|
||||
expect(replaced).toHaveBeenCalledTimes(1);
|
||||
expect(replaced.mock.calls[0][0].path).toBe('list');
|
||||
expect(texts(wrapper)).toEqual(['Y', 'total=2']);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('默认(不传选项)replaced-node 每次变化各触发一次,不丢中间变更', async () => {
|
||||
const app = createApp();
|
||||
const wrapper = mountApp(app);
|
||||
await nextTick();
|
||||
const replaced = vi.fn();
|
||||
app.on('replaced-node', replaced);
|
||||
const ds = app.dataSourceManager!.get('ds')!;
|
||||
|
||||
app.dataSourceManager!.batch(() => {
|
||||
ds.setData(2, 'total');
|
||||
ds.setData([{ label: 'Z' }], 'list');
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(replaced).toHaveBeenCalledTimes(2);
|
||||
expect(replaced.mock.calls.map((call) => call[0].path)).toEqual(['total', 'list']);
|
||||
expect(texts(wrapper)).toEqual(['Z', 'total=2']);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('mergeChangeEvents: false 时 replaced-node 每次变化各触发一次,不丢中间变更', async () => {
|
||||
const app = createApp();
|
||||
const wrapper = mountApp(app);
|
||||
await nextTick();
|
||||
const replaced = vi.fn();
|
||||
app.on('replaced-node', replaced);
|
||||
const ds = app.dataSourceManager!.get('ds')!;
|
||||
|
||||
app.dataSourceManager!.batch(
|
||||
() => {
|
||||
ds.setData(2, 'total');
|
||||
ds.setData([{ label: 'Z' }], 'list');
|
||||
},
|
||||
{ mergeChangeEvents: false },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(replaced).toHaveBeenCalledTimes(2);
|
||||
expect(replaced.mock.calls.map((call) => call[0].path)).toEqual(['total', 'list']);
|
||||
expect(replaced.mock.calls.map((call) => call[0].updateData)).toEqual([2, [{ label: 'Z' }]]);
|
||||
expect(texts(wrapper)).toEqual(['Z', 'total=2']);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('反复更新列表后,EventHelper 上的节点事件监听数量不增长', async () => {
|
||||
const app = createApp();
|
||||
const wrapper = mountApp(app);
|
||||
await nextTick();
|
||||
const helper: any = app.eventHelper;
|
||||
const ds = app.dataSourceManager!.get('ds')!;
|
||||
|
||||
ds.setData([{ label: 'A' }, { label: 'B' }], 'list');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
const baseline = helper.nodeEventList.size;
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
ds.setData([{ label: `A${i}` }, { label: `B${i}` }], 'list');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
console.log(`[e2e-leak] 基线 nodeEventList=${baseline}, 更新20次后=${helper.nodeEventList.size}`);
|
||||
expect(helper.nodeEventList.size).toBe(baseline);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user