roymondchen 0c1d103cd0 fix(runtime): 修复 useDsl 换页后更新失效与重复替换
- 事件处理改为读取最新页面配置(ref),换页后不再更新到旧页面
- react 版事件监听移入 useEffect 并依赖 app,避免监听泄漏
- 按节点对象去重并批量 replaceChildNodes,同一次批量更新的多份相同节点只替换一次
- 忽略非当前页面的 update-data
2026-10-30 16:49:34 +08:00

287 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
});
});