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

209 lines
6.4 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, 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();
});
});