From b5abf310663f0bc34bd2c51c2e1a28de0bdf6ab9 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Fri, 24 Jul 2026 17:17:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(editor):=20=E4=BF=AE=E5=A4=8D=20collectIdle?= =?UTF-8?q?=20=E6=89=B9=E6=AC=A1=E7=BB=93=E7=AE=97=E4=B8=8E=E4=B8=AD?= =?UTF-8?q?=E6=96=AD=E6=97=B6=20Promise=20=E6=8C=82=E8=B5=B7=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引入批次级 Promise 结算,避免快速连续触发或 clearIdleTasks 后 collecting 卡死。 collectIdle 返回 boolean 表示是否完整完成,initService 据此跳过被中断的 stage 更新。 --- packages/editor/src/initService.ts | 40 +++-- packages/editor/src/services/dep.ts | 164 +++++++++++++++--- packages/editor/src/utils/dep/idle-task.ts | 6 + .../editor/tests/unit/initService.spec.ts | 2 +- .../editor/tests/unit/services/dep.spec.ts | 84 ++++++++- 5 files changed, 253 insertions(+), 43 deletions(-) diff --git a/packages/editor/src/initService.ts b/packages/editor/src/initService.ts index 8065c730..69b024bc 100644 --- a/packages/editor/src/initService.ts +++ b/packages/editor/src/initService.ts @@ -333,11 +333,11 @@ export const initServiceEvents = ( Promise.all( nodes.map((node) => { if (node.type === NodeType.ROOT) { - return Promise.resolve(); + return Promise.resolve(true); } return depService.collectIdle([node], { pageId: getPageIdByNode(node) }, deep, type); }), - ); + ).then((results) => results.every(Boolean)); watch( () => editorService.get('stage'), @@ -351,7 +351,7 @@ export const initServiceEvents = ( if (!node) return; - await collectIdle([node], true, DepTargetType.DATA_SOURCE); + if (!(await collectIdle([node], true, DepTargetType.DATA_SOURCE))) return; updateStageNode(node); }); }, @@ -494,8 +494,10 @@ export const initServiceEvents = ( // 新增节点,收集依赖 const nodeAddHandler = (nodes: MComponent[]) => { - collectIdle(nodes, true).then(() => { - updateStageNodes(nodes); + collectIdle(nodes, true).then((completed) => { + if (completed) { + updateStageNodes(nodes); + } }); }; @@ -549,8 +551,8 @@ export const initServiceEvents = ( if (needRecollectNodes.length) { // 有数据源依赖,需要等依赖重新收集完才更新stage const handler = async () => { - await collectIdle(needRecollectNodes, true, DepTargetType.DATA_SOURCE); - await collectIdle(needRecollectNodes, true, DepTargetType.DATA_SOURCE_COND); + if (!(await collectIdle(needRecollectNodes, true, DepTargetType.DATA_SOURCE))) return; + if (!(await collectIdle(needRecollectNodes, true, DepTargetType.DATA_SOURCE_COND))) return; updateStageNodes(needRecollectNodes); }; handler(); @@ -572,8 +574,10 @@ export const initServiceEvents = ( // 历史记录变化时,需要重新收集依赖 const historyChangeHandler = (page: MPage | MPageFragment) => { - collectIdle([page], true).then(() => { - updateStageNode(page); + collectIdle([page], true).then((completed) => { + if (completed) { + updateStageNode(page); + } }); }; @@ -654,7 +658,7 @@ export const initServiceEvents = ( if (Array.isArray(root?.items)) { depService.clearIdleTasks(); - let collectIdlePromises: Promise[] = []; + let collectIdlePromises: Promise[] = []; if (isModifyField) { depService.removeTarget(config.id, DepTargetType.DATA_SOURCE); depService.removeTarget(config.id, DepTargetType.DATA_SOURCE_COND); @@ -679,10 +683,15 @@ export const initServiceEvents = ( collectIdlePromises = [collectIdle(root.items, true, DepTargetType.DATA_SOURCE_METHOD)]; } - Promise.all(collectIdlePromises) - .then(() => updateDataSourceSchema()) - .then(() => updateDsData()) - .then(() => updateStageNodes(root.items)); + const handler = async () => { + const results = await Promise.all(collectIdlePromises); + if (!results.every(Boolean)) return; + + updateDataSourceSchema(); + await updateDsData(); + updateStageNodes(root.items); + }; + handler(); } } else if (root?.dataSources) { updateDsData(); @@ -706,11 +715,12 @@ export const initServiceEvents = ( const nodeIds = Object.keys(root.dataSourceDeps?.[id] || {}); const nodes = getNodes(nodeIds, root.items); - await Promise.all([ + const results = await Promise.all([ collectIdle(nodes, false, DepTargetType.DATA_SOURCE), collectIdle(nodes, false, DepTargetType.DATA_SOURCE_COND), collectIdle(nodes, false, DepTargetType.DATA_SOURCE_METHOD), ]); + if (!results.every(Boolean)) return; updateDataSourceSchema(); diff --git a/packages/editor/src/services/dep.ts b/packages/editor/src/services/dep.ts index 1cb81d27..74c3b313 100644 --- a/packages/editor/src/services/dep.ts +++ b/packages/editor/src/services/dep.ts @@ -42,6 +42,21 @@ interface State { type StateKey = keyof State; +/** + * 一次 collectIdle 调用对应一个批次。批次自行统计入队/完成的任务数,在自身任务全部完成或被中断时 + * 结算对应的 Promise,避免多次快速触发时共用 idleTask 的 finish 事件造成的串扰、提前 resolve, + * 以及 clearTasks 后 Promise 永不 resolve(collecting 卡死、监听器泄漏)。 + */ +interface CollectBatch { + nodes: MNode[]; + deep: boolean; + pending: number; + dsPending: number; + collectedEmitted: boolean; + dsSettled: boolean; + resolve: (completed: boolean) => void; +} + class Dep extends BaseService { private state = shallowReactive({ collecting: false, @@ -54,6 +69,12 @@ class Dep extends BaseService { private waitingWorker?: Promise; + private resolveWaitingWorker?: () => void; + + private workerGeneration = 0; + + private activeBatches = new Set(); + constructor() { super(); @@ -137,38 +158,44 @@ class Dep extends BaseService { await this.waitingWorker; } - this.set('collecting', true); - let startTask = false; - this.watcher.collectByCallback(nodes, type, ({ node, target }) => { - startTask = true; + const batch: CollectBatch = { + nodes, + deep, + pending: 0, + dsPending: 0, + collectedEmitted: false, + dsSettled: false, + resolve: () => {}, + }; - this.enqueueTask(node, target, depExtendedData, deep); + this.watcher.collectByCallback(nodes, type, ({ node, target }) => { + this.enqueueTask(node, target, depExtendedData, deep, batch); }); - return new Promise((resolve) => { - if (!startTask) { - this.emit('collected', nodes, deep); - this.set('collecting', false); - resolve(); - return; - } - this.idleTask.once('finish', () => { - this.emit('collected', nodes, deep); - this.set('collecting', false); - }); - this.idleTask.once('hight-level-finish', () => { - this.emit('ds-collected', nodes, deep); - resolve(); - }); + // 没有命中任何 target,无需收集,直接完成 + if (batch.pending === 0) { + this.emit('collected', nodes, deep); + this.updateCollectingState(); + return true; + } + + this.activeBatches.add(batch); + this.set('collecting', true); + + return new Promise((resolve) => { + batch.resolve = resolve; }); } public collectByWorker(dsl: MApp) { this.set('collecting', true); + this.workerGeneration += 1; + const generation = this.workerGeneration; const { promise, resolve: waitingResolve } = Promise.withResolvers(); this.waitingWorker = promise; + this.resolveWaitingWorker = waitingResolve; return new Promise>>((resolve) => { const worker = new Work(); @@ -182,6 +209,11 @@ class Dep extends BaseService { resolve({}); }; }).then((depsData) => { + if (generation !== this.workerGeneration) { + waitingResolve(); + return depsData; + } + traverseTarget(this.watcher.getTargetsList(), (target) => { if (depsData[target.type]?.[target.id]) { target.deps = reactive(depsData[target.type][target.id]); @@ -200,6 +232,10 @@ class Dep extends BaseService { this.emit('collected', dsl.items, true); this.emit('ds-collected', dsl.items, true); waitingResolve(); + if (this.waitingWorker === promise) { + this.waitingWorker = undefined; + this.resolveWaitingWorker = undefined; + } return depsData; }); @@ -233,6 +269,7 @@ class Dep extends BaseService { } public clearIdleTasks() { + this.abortActiveBatches(); this.idleTask.clearTasks(); } @@ -251,6 +288,11 @@ class Dep extends BaseService { } public reset() { + this.abortActiveBatches(); + this.workerGeneration += 1; + this.resolveWaitingWorker?.(); + this.resolveWaitingWorker = undefined; + this.waitingWorker = undefined; this.idleTask.clearTasks(); for (const type of Object.keys(this.watcher.getTargetsList())) { @@ -286,25 +328,99 @@ class Dep extends BaseService { } } - private enqueueTask(node: MNode, target: Target, depExtendedData: DepExtendedData, deep: boolean) { + private enqueueTask( + node: MNode, + target: Target, + depExtendedData: DepExtendedData, + deep: boolean, + batch: CollectBatch, + ) { + const isDataSource = target.type === DepTargetType.DATA_SOURCE; + + batch.pending += 1; + if (isDataSource) { + batch.dsPending += 1; + } + this.idleTask.enqueueTask( ({ node, deep, target }) => { - this.collectNode(node, target, depExtendedData, deep); + try { + this.collectNode(node, target, depExtendedData, deep); + } finally { + this.onBatchTaskDone(batch, isDataSource); + } }, { node, deep: false, target, }, - target.type === DepTargetType.DATA_SOURCE, + isDataSource, ); if (deep && Array.isArray(node.items) && node.items.length) { node.items.forEach((item) => { - this.enqueueTask(item, target, depExtendedData, deep); + this.enqueueTask(item, target, depExtendedData, deep, batch); }); } } + + private onBatchTaskDone(batch: CollectBatch, isDataSource: boolean) { + if (isDataSource) { + batch.dsPending -= 1; + // 数据源依赖收集完先 resolve,让 stage 尽快更新,其余依赖继续在后台收集 + if (batch.dsPending === 0) { + this.settleBatchDs(batch); + } + } + + batch.pending -= 1; + if (batch.pending === 0) { + this.finishBatch(batch); + } + } + + private settleBatchDs(batch: CollectBatch) { + if (batch.dsSettled) return; + batch.dsSettled = true; + this.emit('ds-collected', batch.nodes, batch.deep); + batch.resolve(true); + } + + private finishBatch(batch: CollectBatch) { + if (!batch.collectedEmitted) { + batch.collectedEmitted = true; + this.emit('collected', batch.nodes, batch.deep); + } + // 没有数据源任务的批次在此结算 Promise + this.settleBatchDs(batch); + this.activeBatches.delete(batch); + this.updateCollectingState(); + } + + /** + * idleTask 被清空时,在途批次的任务不会再执行,必须主动结算, + * 否则对应的 collectIdle Promise 永远不会 resolve(collecting 卡在 true、once 监听器泄漏)。 + * 收集被中断(通常紧跟一次全量重新收集),因此不再 emit collected/ds-collected,仅结算 Promise。 + */ + private abortActiveBatches() { + if (!this.activeBatches.size) { + return; + } + + const batches = [...this.activeBatches]; + this.activeBatches.clear(); + + for (const batch of batches) { + batch.resolve(false); + } + + this.updateCollectingState(); + } + + private updateCollectingState() { + this.set('collecting', this.activeBatches.size > 0); + } } export type DepService = Dep; diff --git a/packages/editor/src/utils/dep/idle-task.ts b/packages/editor/src/utils/dep/idle-task.ts index 196ff0b0..7ef8de6c 100644 --- a/packages/editor/src/utils/dep/idle-task.ts +++ b/packages/editor/src/utils/dep/idle-task.ts @@ -25,6 +25,12 @@ globalThis.requestIdleCallback = }, 1); }; +globalThis.cancelIdleCallback = + globalThis.cancelIdleCallback || + function (handle) { + clearTimeout(handle as unknown as ReturnType); + }; + export class IdleTask extends EventEmitter { private taskList: TaskList = []; diff --git a/packages/editor/tests/unit/initService.spec.ts b/packages/editor/tests/unit/initService.spec.ts index f30d2d6b..0f78f6fb 100644 --- a/packages/editor/tests/unit/initService.spec.ts +++ b/packages/editor/tests/unit/initService.spec.ts @@ -91,7 +91,7 @@ const mkServices = () => { clear: vi.fn(), clearTargets: vi.fn(), clearIdleTasks: vi.fn(), - collectIdle: vi.fn(async () => undefined), + collectIdle: vi.fn(async () => true), collectByWorker: vi.fn(async () => undefined), reset: vi.fn(), }; diff --git a/packages/editor/tests/unit/services/dep.spec.ts b/packages/editor/tests/unit/services/dep.spec.ts index c12b3baa..9a796b74 100644 --- a/packages/editor/tests/unit/services/dep.spec.ts +++ b/packages/editor/tests/unit/services/dep.spec.ts @@ -13,16 +13,17 @@ vi.mock('@editor/utils/dep/worker.ts?worker&inline', () => ({ default: class FakeWorker { public static nextData: Record = {}; public static nextError = false; + public static nextDelay = 0; public onmessage: ((e: any) => void) | null = null; public onerror: (() => void) | null = null; public postMessage() { setTimeout(() => { if (FakeWorker.nextError) { - this.onerror?.(new Event('error')); + this.onerror?.(); return; } this.onmessage?.({ data: FakeWorker.nextData }); - }, 0); + }, FakeWorker.nextDelay); } }, })); @@ -121,7 +122,7 @@ describe('Dep service', () => { test('collectIdle - 没有命中时立即 resolve 并 emit collected', async () => { const fn = vi.fn(); depService.on('collected', fn); - await depService.collectIdle([{ id: 'n1', type: 'text' }] as any); + await expect(depService.collectIdle([{ id: 'n1', type: 'text' }] as any)).resolves.toBe(true); expect(fn).toHaveBeenCalled(); depService.off('collected', fn); }); @@ -219,6 +220,83 @@ describe('Dep service', () => { fakeWorker.nextData = {}; }); + test('collectIdle 命中 target 时最终 resolve 并按批次 emit collected/ds-collected', async () => { + depService.addTarget(makeTarget('ds1', DepTargetType.DATA_SOURCE)); + const collected = vi.fn(); + const dsCollected = vi.fn(); + depService.on('collected', collected); + depService.on('ds-collected', dsCollected); + + const nodes = [{ id: 'n1', type: 'text' }] as any; + await expect(depService.collectIdle(nodes, {}, false, DepTargetType.DATA_SOURCE)).resolves.toBe(true); + + expect(dsCollected).toHaveBeenCalledWith(nodes, false); + expect(collected).toHaveBeenCalledWith(nodes, false); + expect(depService.get('collecting')).toBe(false); + + depService.off('collected', collected); + depService.off('ds-collected', dsCollected); + }); + + test('clearIdleTasks 会结算在途 collectIdle,避免 Promise 永久挂起且 collecting 复位', async () => { + depService.addTarget(makeTarget('ds1', DepTargetType.DATA_SOURCE)); + + const promise = depService.collectIdle([{ id: 'n1', type: 'text' }] as any, {}, false, DepTargetType.DATA_SOURCE); + expect(depService.get('collecting')).toBe(true); + + // 快速触发:任务尚未执行就清空队列,批次应被主动结算而不是永久挂起 + depService.clearIdleTasks(); + + await expect(promise).resolves.toBe(false); + expect(depService.get('collecting')).toBe(false); + }); + + test('reset 会结算在途 collectIdle', async () => { + depService.addTarget(makeTarget('ds1', DepTargetType.DATA_SOURCE)); + + const promise = depService.collectIdle([{ id: 'n1', type: 'text' }] as any, {}, false, DepTargetType.DATA_SOURCE); + depService.reset(); + + await expect(promise).resolves.toBe(false); + expect(depService.get('collecting')).toBe(false); + }); + + test('reset 会忽略在途 worker 的过期结果,避免覆盖新依赖', async () => { + const fakeWorker = (await import('@editor/utils/dep/worker.ts?worker&inline')).default as any; + fakeWorker.nextDelay = 20; + fakeWorker.nextData = { + [DepTargetType.DATA_SOURCE]: { ds1: { n1: { data: {} } } }, + }; + + const workerPromise = depService.collectByWorker({ items: [], id: 'app', type: 'app' } as any); + depService.reset(); + + const target = makeTarget('ds1', DepTargetType.DATA_SOURCE); + depService.addTarget(target); + const idlePromise = depService.collectIdle( + [{ id: 'n1', type: 'text' }] as any, + {}, + false, + DepTargetType.DATA_SOURCE, + ); + + await Promise.all([workerPromise, idlePromise]); + expect(target.deps.n1).toBeUndefined(); + + fakeWorker.nextDelay = 0; + fakeWorker.nextData = {}; + }); + + test('多个批次并发时各自独立 resolve,全部完成后 collecting 复位', async () => { + depService.addTarget(makeTarget('ds1', DepTargetType.DATA_SOURCE)); + + const p1 = depService.collectIdle([{ id: 'n1', type: 'text' }] as any, {}, false, DepTargetType.DATA_SOURCE); + const p2 = depService.collectIdle([{ id: 'n2', type: 'text' }] as any, {}, false, DepTargetType.DATA_SOURCE); + + await Promise.all([p1, p2]); + expect(depService.get('collecting')).toBe(false); + }); + test('destroy 会 reset 并移除监听', () => { depService.addTarget(makeTarget('destroy-me')); expect(() => depService.destroy()).not.toThrow();