From 0c1d103cd054caa49de70af6b97488c8474f2c27 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Fri, 30 Oct 2026 16:49:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(runtime):=20=E4=BF=AE=E5=A4=8D=20useDsl=20?= =?UTF-8?q?=E6=8D=A2=E9=A1=B5=E5=90=8E=E6=9B=B4=E6=96=B0=E5=A4=B1=E6=95=88?= =?UTF-8?q?=E4=B8=8E=E9=87=8D=E5=A4=8D=E6=9B=BF=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 事件处理改为读取最新页面配置(ref),换页后不再更新到旧页面 - react 版事件监听移入 useEffect 并依赖 app,避免监听泄漏 - 按节点对象去重并批量 replaceChildNodes,同一次批量更新的多份相同节点只替换一次 - 忽略非当前页面的 update-data --- .../react-runtime-help/src/hooks/use-dsl.ts | 108 +++++-- .../react-runtime-help/tests/use-dsl.test.ts | 208 +++++++++++++ runtime/vue-runtime-help/src/hooks/use-dsl.ts | 33 +- .../tests/iterator-e2e.test.ts | 286 ++++++++++++++++++ 4 files changed, 604 insertions(+), 31 deletions(-) create mode 100644 runtime/react-runtime-help/tests/use-dsl.test.ts create mode 100644 runtime/vue-runtime-help/tests/iterator-e2e.test.ts diff --git a/runtime/react-runtime-help/src/hooks/use-dsl.ts b/runtime/react-runtime-help/src/hooks/use-dsl.ts index 2f97b842..12758d65 100644 --- a/runtime/react-runtime-help/src/hooks/use-dsl.ts +++ b/runtime/react-runtime-help/src/hooks/use-dsl.ts @@ -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 | 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 }; }; diff --git a/runtime/react-runtime-help/tests/use-dsl.test.ts b/runtime/react-runtime-help/tests/use-dsl.test.ts new file mode 100644 index 00000000..e7d1279e --- /dev/null +++ b/runtime/react-runtime-help/tests/use-dsl.test.ts @@ -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; + 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(); + }); +}); diff --git a/runtime/vue-runtime-help/src/hooks/use-dsl.ts b/runtime/vue-runtime-help/src/hooks/use-dsl.ts index 95c42e4b..22a8b2f5 100644 --- a/runtime/vue-runtime-help/src/hooks/use-dsl.ts +++ b/runtime/vue-runtime-help/src/hooks/use-dsl.ts @@ -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('app'), pageFragmentContainerId?: Id) => { if (!app) { @@ -11,10 +11,29 @@ export const useDsl = (app = inject('app'), pageFragmentContainerId?: const pageFragment = pageFragmentContainerId ? app.pageFragments.get(pageFragmentContainerId) : null; + // 同一次 batch 的多个 update-data 携带的是同一份已编译节点,重复替换内容相同却要再付一次全树遍历, + // 因此按节点对象去重:同一个节点对象只替换一次(非 batch 场景下每次编译都是新对象,不受影响) + let appliedNodes: WeakSet = 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(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('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('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 }); diff --git a/runtime/vue-runtime-help/tests/iterator-e2e.test.ts b/runtime/vue-runtime-help/tests/iterator-e2e.test.ts new file mode 100644 index 00000000..d9478638 --- /dev/null +++ b/runtime/vue-runtime-help/tests/iterator-e2e.test.ts @@ -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) => + 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(); + }); +});