From cc94be7083cad47944855729a4b050ec7e98013e Mon Sep 17 00:00:00 2001 From: zahid emre zorbaz Date: Tue, 6 Oct 2026 23:02:23 +0300 Subject: [PATCH 1/9] perf(core): sweep-based overlap check before the pairwise diagnostic loop Validation, compactor result checks and the engine collision helpers ran an O(n^2) pairwise loop on every layout, even when layouts have no collision. hasAnyCollision sorts items by their top edge (y) and sweeps once; the pairwise loop now only runs when the sweep found an overlap, to report the same first colliding item as before. --- .changeset/sweep-collision-checks.md | 5 ++ src/core/collision-sweep.ts | 34 ++++++++++++++ src/core/layout-engine.ts | 8 ++-- src/core/normalize.ts | 20 ++++---- tests/collision-sweep.spec.ts | 70 ++++++++++++++++++++++++++++ 5 files changed, 125 insertions(+), 12 deletions(-) create mode 100644 .changeset/sweep-collision-checks.md create mode 100644 src/core/collision-sweep.ts create mode 100644 tests/collision-sweep.spec.ts diff --git a/.changeset/sweep-collision-checks.md b/.changeset/sweep-collision-checks.md new file mode 100644 index 0000000..f4988f1 --- /dev/null +++ b/.changeset/sweep-collision-checks.md @@ -0,0 +1,5 @@ +--- +'grid-layout-plus': patch +--- + +Speed up overlap detection with a top-edge sweep before the pairwise diagnostic loop, especially for vertically stacked collision-free layouts. diff --git a/src/core/collision-sweep.ts b/src/core/collision-sweep.ts new file mode 100644 index 0000000..10c5d48 --- /dev/null +++ b/src/core/collision-sweep.ts @@ -0,0 +1,34 @@ +import type { ReadonlyLayout } from '../helpers/types' + +/** + * 判断布局中是否存在任意两项重叠。按上边缘扫描,只保留在当前行仍然“打开”的项, + * 因此堆叠(移动端)布局中每项只需与少数几项比较,而非全部。仅边缘相接不算重叠, + * id 相同的项也不计为重叠,与逐对的 `collides` 检查一致。 + * + * 只回答“是否存在”:需要报告具体冲突项的调用方在本函数返回 `true` 后再执行逐对循环, + * 因此报告的索引与路径保持不变。 + */ +export function hasAnyCollision(layout: ReadonlyLayout): boolean { + const order = Array.from(layout.keys()).sort((a, b) => layout[a].y - layout[b].y) + const active: number[] = [] + for (const index of order) { + const item = layout[index] + let kept = 0 + for (const other of active) { + const candidate = layout[other] + if (candidate.y + candidate.h <= item.y) continue + active[kept++] = other + if ( + !Object.is(candidate.i, item.i) && + candidate.x < item.x + item.w && + candidate.x + candidate.w > item.x && + candidate.y < item.y + item.h + ) { + return true + } + } + active.length = kept + active.push(index) + } + return false +} diff --git a/src/core/layout-engine.ts b/src/core/layout-engine.ts index f7bbf2a..326bca5 100644 --- a/src/core/layout-engine.ts +++ b/src/core/layout-engine.ts @@ -1,4 +1,5 @@ import { cloneLayout } from '../helpers/common' +import { hasAnyCollision } from './collision-sweep' import { noCompactor, verticalCompactor } from './compactors' import { GridLayoutExtensionError, GridLayoutValidationError } from './errors' import { normalizeLayout } from './normalize' @@ -516,7 +517,7 @@ function snapshotLayout( } } - if (!allowCollisions && config.collisionMode !== 'overlap') { + if (!allowCollisions && config.collisionMode !== 'overlap' && hasAnyCollision(layout)) { for (let index = 0; index < layout.length; index++) { for (let otherIndex = 0; otherIndex < index; otherIndex++) { if (collides(layout[index], layout[otherIndex])) { @@ -737,12 +738,11 @@ function propagateActive( } function hasCollision(layout: ReadonlyLayout): boolean { - return layout.some((item, index) => layout.slice(0, index).some(other => collides(item, other))) + return hasAnyCollision(layout) } function hasStaticCollision(layout: ReadonlyLayout): boolean { - const statics = layout.filter(item => item.static) - return statics.some((item, index) => statics.slice(0, index).some(other => collides(item, other))) + return hasAnyCollision(layout.filter(item => item.static)) } function normalizeFullLayout(layout: ReadonlyLayout, config: InternalEffectiveConfig): Layout { diff --git a/src/core/normalize.ts b/src/core/normalize.ts index 41266e6..9bd4dcc 100644 --- a/src/core/normalize.ts +++ b/src/core/normalize.ts @@ -1,4 +1,5 @@ import { cloneLayout } from '../helpers/common' +import { hasAnyCollision } from './collision-sweep' import { noCompactor, verticalCompactor } from './compactors' import { GridLayoutExtensionError, GridLayoutValidationError } from './errors' import { assertPositiveSafeInteger, readPlainDataObject, snapshotCompactor } from './validation' @@ -233,10 +234,12 @@ function validateCompactorResult( } } - for (let index = 0; index < output.length; index++) { - for (let otherIndex = 0; otherIndex < index; otherIndex++) { - if (collides(output[index], output[otherIndex])) { - extensionFailure('extension-invalid-result', result) + if (hasAnyCollision(output)) { + for (let index = 0; index < output.length; index++) { + for (let otherIndex = 0; otherIndex < index; otherIndex++) { + if (collides(output[index], output[otherIndex])) { + extensionFailure('extension-invalid-result', result) + } } } } @@ -310,10 +313,11 @@ export function normalizeLayout( if (collisionMode === 'overlap') return normalized // `prevent` 在压缩前直接拒绝碰撞;`push` 则先建立无碰撞放置,再交给扩展压缩。 - for (let index = 0; index < normalized.length; index++) { - for (let otherIndex = 0; otherIndex < index; otherIndex++) { - if (collides(normalized[index], normalized[otherIndex])) { - if (collisionMode === 'prevent') { + // 逐对循环只在 `prevent` 下起作用,且只负责指出第一个碰撞项;是否存在碰撞由扫描判定。 + if (collisionMode === 'prevent' && hasAnyCollision(normalized)) { + for (let index = 0; index < normalized.length; index++) { + for (let otherIndex = 0; otherIndex < index; otherIndex++) { + if (collides(normalized[index], normalized[otherIndex])) { failLayout(`layout[${index}]`, normalized[index]) } } diff --git a/tests/collision-sweep.spec.ts b/tests/collision-sweep.spec.ts new file mode 100644 index 0000000..676ce3b --- /dev/null +++ b/tests/collision-sweep.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' + +import { hasAnyCollision } from '../src/core/collision-sweep' +import { collides } from '../src/helpers/common' + +import type { Layout } from '../src' + +function naive(layout: Layout): boolean { + for (let i = 0; i < layout.length; i++) { + for (let j = 0; j < i; j++) if (collides(layout[i], layout[j])) return true + } + return false +} + +function rng(seed: number) { + let s = seed >>> 0 + return () => (s = (Math.imul(s, 1664525) + 1013904223) >>> 0) / 2 ** 32 +} + +describe('hasAnyCollision', () => { + it('在 3000 个随机布局上与逐对检查结果一致', () => { + for (let seed = 1; seed <= 3000; seed++) { + const r = rng(seed) + const cols = 1 + Math.floor(r() * 36) + const layout = Array.from({ length: Math.floor(r() * 60) }, (_, i) => { + const w = 1 + Math.floor(r() * cols) + return { + i, + x: Math.floor(r() * (cols - w + 1)), + y: Math.floor(r() * 50), + w, + h: 1 + Math.floor(r() * 6), + } + }) + expect(hasAnyCollision(layout), `seed ${seed}`).toBe(naive(layout)) + } + }) + + it('边缘相接不算碰撞', () => { + expect( + hasAnyCollision([ + { i: 1, x: 0, y: 0, w: 2, h: 2 }, + { i: 2, x: 2, y: 0, w: 2, h: 2 }, + { i: 3, x: 0, y: 2, w: 2, h: 2 }, + ]), + ).toBe(false) + }) + + it('与逐对检查一致,忽略 id 相同的元素', () => { + expect( + hasAnyCollision([ + { i: 'a', x: 0, y: 0, w: 2, h: 2 }, + { i: 'a', x: 0, y: 0, w: 2, h: 2 }, + ]), + ).toBe(false) + }) + + it('1000 个元素纵向堆叠的移动端布局仍然很快', () => { + let y = 0 + const layout = Array.from({ length: 1000 }, (_, i) => { + const h = 1 + (i % 7) + const item = { i, x: 0, y, w: 12, h } + y += h + return item + }) + const start = performance.now() + for (let k = 0; k < 20; k++) hasAnyCollision(layout) + expect((performance.now() - start) / 20).toBeLessThan(2) + }) +}) From fdfffc231f530df53bc4a9f53cce5fb8ee29cbbc Mon Sep 17 00:00:00 2001 From: zahid emre zorbaz Date: Tue, 6 Oct 2026 23:02:49 +0300 Subject: [PATCH 2/9] perf(core): copy engine-sealed layouts without re-validation The engine's committed layout, session base and working copies and each evaluation's next layout are now deep-frozen after their validating clone and recorded in a private WeakSet. Snapshotting a recorded array copies it field by field instead of re-reading every descriptor, and states that never change are shared instead of cloned. External inputs keep the validating path. Returned and emitted layout arrays and items remain mutable copies, isolated from internal state. --- .changeset/copy-sealed-layouts.md | 5 + src/core/layout-engine.ts | 64 +++++++----- src/helpers/common.ts | 63 +++++++++++ tests/layout-seal.spec.ts | 167 ++++++++++++++++++++++++++++++ 4 files changed, 271 insertions(+), 28 deletions(-) create mode 100644 .changeset/copy-sealed-layouts.md create mode 100644 tests/layout-seal.spec.ts diff --git a/.changeset/copy-sealed-layouts.md b/.changeset/copy-sealed-layouts.md new file mode 100644 index 0000000..0e49e63 --- /dev/null +++ b/.changeset/copy-sealed-layouts.md @@ -0,0 +1,5 @@ +--- +'grid-layout-plus': patch +--- + +Copy the engine's internal frozen layouts without re-validating every item, reducing per-drag-step cloning cost. diff --git a/src/core/layout-engine.ts b/src/core/layout-engine.ts index 326bca5..c0c2f5d 100644 --- a/src/core/layout-engine.ts +++ b/src/core/layout-engine.ts @@ -1,4 +1,4 @@ -import { cloneLayout } from '../helpers/common' +import { cloneLayout, sealLayout } from '../helpers/common' import { hasAnyCollision } from './collision-sweep' import { noCompactor, verticalCompactor } from './compactors' import { GridLayoutExtensionError, GridLayoutValidationError } from './errors' @@ -154,11 +154,11 @@ interface InternalLayoutEngineInitializationOptions extends LayoutReplacementOpt */ interface EvaluationRecord { readonly evaluation: LayoutEngineEvaluation - readonly nextLayout: Layout + readonly nextLayout: ReadonlyLayout readonly nextConfig: InternalEffectiveConfig readonly changesConfig: boolean interactionRecord?: SessionRecord - previousWorking?: Layout + previousWorking?: ReadonlyLayout previousWorkingEvaluation?: LayoutEngineEvaluation | null previousLayerIntent?: boolean state: 'pending' | 'confirmed' | 'rolled-back' @@ -170,8 +170,8 @@ interface SessionRecord { readonly session: InternalInteractionSession readonly type: 'drag' | 'resize' readonly id: LayoutItem['i'] - baseLayout: Layout - latestWorking: Layout + baseLayout: ReadonlyLayout + latestWorking: ReadonlyLayout workingEvaluation: LayoutEngineEvaluation | null active: boolean layerIntent: boolean @@ -179,7 +179,7 @@ interface SessionRecord { interface OperationAttempt { readonly result: LayoutOperationResult - readonly nextLayout: Layout + readonly nextLayout: ReadonlyLayout readonly nextConfig: InternalEffectiveConfig readonly metrics: InternalContainerMetrics readonly failure: InternalEngineFailure | null @@ -1108,6 +1108,8 @@ function evaluateCommandLayout( } const status = layoutsSemanticallyEqual(nextLayout, compareLayout) ? 'unchanged' : 'accepted' + // 先校验克隆并封存一次:结果物化与评估记录共用它,结果侧只需快速复制。 + const sealedNext = sealLayout(cloneLayout(nextLayout)) return { result: materializeResult( status, @@ -1115,12 +1117,12 @@ function evaluateCommandLayout( operation, id, publicPrevious, - nextLayout, + sealedNext, command.type === 'set' || command.type === 'config' || command.type === 'auto-resize' ? null : candidate, ), - nextLayout: cloneLayout(nextLayout), + nextLayout: sealedNext, nextConfig, metrics, failure: null, @@ -1191,11 +1193,15 @@ function createLayoutEngineInternal( options: InternalLayoutEngineInitializationOptions = {}, ): LayoutEnginePort { let committedConfig = snapshotEffectiveConfig(initialConfig) - let committedLayout = snapshotLayout( - initialLayout, - committedConfig, - options.deferHorizontalBounds ?? false, - options.allowInitialCollisions ?? false, + // 闭包内的已提交布局、会话基线与工作副本均为封存(深度冻结)布局:它们从不直接外泄, + // 对外一律经 cloneLayout 复制,封存后的复制跳过重复校验;内部误写会因冻结立即抛错。 + let committedLayout = sealLayout( + snapshotLayout( + initialLayout, + committedConfig, + options.deferHorizontalBounds ?? false, + options.allowInitialCollisions ?? false, + ), ) let version = 1 let sessionSequence = 0 @@ -1217,7 +1223,7 @@ function createLayoutEngineInternal( } evaluations.set(evaluation, { evaluation, - nextLayout: attempt.nextLayout, + nextLayout: sealLayout(attempt.nextLayout as Layout), nextConfig: attempt.nextConfig, changesConfig: !configEqual(committedConfig, attempt.nextConfig), state: 'pending', @@ -1253,7 +1259,7 @@ function createLayoutEngineInternal( previousEvaluation = previousRecord.previousWorkingEvaluation previousRecord = previousEvaluation ? evaluations.get(previousEvaluation) : undefined } - interaction.latestWorking = cloneLayout(previousWorking) + interaction.latestWorking = previousWorking interaction.layerIntent = previousLayerIntent ?? interaction.layerIntent interaction.workingEvaluation = previousEvaluation && previousRecord?.state === 'pending' ? previousEvaluation : null @@ -1270,7 +1276,7 @@ function createLayoutEngineInternal( return makeEvaluation( { result, - nextLayout: cloneLayout(committedLayout), + nextLayout: committedLayout, nextConfig: committedConfig, metrics: calculateContainerMetrics(committedLayout, committedConfig), failure: null, @@ -1339,8 +1345,8 @@ function createLayoutEngineInternal( session, type: command.type, id: command.id, - baseLayout: cloneLayout(committedLayout), - latestWorking: cloneLayout(committedLayout), + baseLayout: committedLayout, + latestWorking: committedLayout, workingEvaluation: null, active: true, layerIntent: @@ -1360,7 +1366,7 @@ function createLayoutEngineInternal( return makeEvaluation( { result: rejectedResult(operation, null, committedLayout, 'cancelled'), - nextLayout: cloneLayout(committedLayout), + nextLayout: committedLayout, nextConfig: committedConfig, metrics: calculateContainerMetrics(committedLayout, committedConfig), failure: null, @@ -1372,7 +1378,7 @@ function createLayoutEngineInternal( return makeEvaluation( { result: rejectedResult(operation, record.id, committedLayout, 'invalid-input'), - nextLayout: cloneLayout(committedLayout), + nextLayout: committedLayout, nextConfig: committedConfig, metrics: calculateContainerMetrics(committedLayout, committedConfig), failure: null, @@ -1391,7 +1397,7 @@ function createLayoutEngineInternal( w: command.w, h: command.h, } - const previousWorking = cloneLayout(record.latestWorking) + const previousWorking = record.latestWorking const previousWorkingEvaluation = record.workingEvaluation const previousLayerIntent = record.layerIntent const algorithmBase = @@ -1415,10 +1421,10 @@ function createLayoutEngineInternal( } const evaluation = makeEvaluation(attempt, baseVersion) if (attempt.result.status === 'accepted') { - record.latestWorking = cloneLayout(attempt.nextLayout) + const evaluationRecord = evaluations.get(evaluation)! + record.latestWorking = evaluationRecord.nextLayout record.workingEvaluation = evaluation record.layerIntent = false - const evaluationRecord = evaluations.get(evaluation)! evaluationRecord.interactionRecord = record evaluationRecord.previousWorking = previousWorking evaluationRecord.previousWorkingEvaluation = previousWorkingEvaluation @@ -1460,7 +1466,7 @@ function createLayoutEngineInternal( null, ) if (changed) { - committedLayout = cloneLayout(nextLayout) + committedLayout = sealLayout(nextLayout) committedConfig = nextConfig version = nextVersion(version) } @@ -1468,11 +1474,13 @@ function createLayoutEngineInternal( }, mergeExternalMetadata(layout) { - const next = mergeLayoutMetadata(committedLayout, layout) + const next = sealLayout(mergeLayoutMetadata(committedLayout, layout)) committedLayout = next if (activeSession) { - activeSession.baseLayout = mergeLayoutMetadata(activeSession.baseLayout, next) - activeSession.latestWorking = mergeLayoutMetadata(activeSession.latestWorking, next) + activeSession.baseLayout = sealLayout(mergeLayoutMetadata(activeSession.baseLayout, next)) + activeSession.latestWorking = sealLayout( + mergeLayoutMetadata(activeSession.latestWorking, next), + ) } version = nextVersion(version) return cloneLayout(committedLayout) @@ -1503,7 +1511,7 @@ function createLayoutEngineInternal( return record.finalResult } if (evaluation.result.status === 'accepted' || record.changesConfig) { - committedLayout = cloneLayout(record.nextLayout) + committedLayout = record.nextLayout committedConfig = record.nextConfig version = nextVersion(version) } diff --git a/src/helpers/common.ts b/src/helpers/common.ts index 04206ea..29a52b2 100644 --- a/src/helpers/common.ts +++ b/src/helpers/common.ts @@ -245,11 +245,74 @@ function cloneLayoutItemAt( return result as unknown as LayoutItem } +// 仅记录由本库校验克隆产出、随后被深度冻结的布局数组。外部传入的数组从不进入此集合: +// 标记只在 sealLayout 中添加,而 sealLayout 只接收库内部刚克隆出的布局。 +const sealedLayouts = new WeakSet() + +function freezeMetadataValue(value: unknown): void { + if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return + for (const key of Object.keys(value)) { + freezeMetadataValue((value as Record)[key]) + } + Object.freeze(value) +} + +/** + * 深度冻结并标记一个由 cloneLayout(或同等校验克隆)新产出的内部布局。 + * 调用方必须保证该数组及其所有项从未暴露给外部,且之后不再修改;冻结保证任何遗漏的写入立即抛错。 + */ +export function sealLayout(layout: Layout): ReadonlyLayout { + if (sealedLayouts.has(layout)) return layout + for (const item of layout) freezeMetadataValue(item) + Object.freeze(layout) + sealedLayouts.add(layout) + return layout +} + +export function isSealedLayout(layout: unknown): boolean { + return typeof layout === 'object' && layout !== null && sealedLayouts.has(layout) +} + +// 已封存的数据只可能来自校验克隆的结果,因此无需再次按 descriptor 校验,只做结构复制: +// 保持键顺序、原型(metadata 可能为 null 原型)与冻结的 resizeHandles 语义。 +function copySealedMetadata(value: unknown): unknown { + if (typeof value !== 'object' || value === null) return value + if (Array.isArray(value)) return value.map(copySealedMetadata) + const result: Record = + Object.getPrototypeOf(value) === null ? Object.create(null) : {} + for (const key of Object.keys(value)) { + defineDataProperty(result, key, copySealedMetadata((value as Record)[key])) + } + return result +} + +function copySealedItem(item: ReadonlyLayoutItem): LayoutItem { + const result: Record = {} + for (const key of Object.keys(item)) { + const value = (item as unknown as Record)[key] + defineDataProperty( + result, + key, + KNOWN_LAYOUT_ITEM_KEYS.has(key) + ? key === 'resizeHandles' + ? Object.freeze(Array.from(value as readonly string[])) + : value + : copySealedMetadata(value), + ) + } + return result as unknown as LayoutItem +} + function snapshotLayout( value: unknown, contextName = 'layout', options: Readonly<{ allowNegativeX?: boolean }> = {}, ): LayoutSnapshot { + if (isSealedLayout(value)) { + // 封存布局的 x 均非负,因此同时满足 allowNegativeX 的两种取值。 + const sealed = value as ReadonlyLayout + return { layout: sealed.map(copySealedItem), sources: Array.from(sealed) } + } if (!Array.isArray(value)) failLayout(contextName, value) const lengthDescriptor = getOwnDescriptor(value, 'length', 'invalid-layout', contextName) diff --git a/tests/layout-seal.spec.ts b/tests/layout-seal.spec.ts new file mode 100644 index 0000000..0437003 --- /dev/null +++ b/tests/layout-seal.spec.ts @@ -0,0 +1,167 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const cloneCounter = vi.hoisted(() => ({ items: 0 })) + +vi.mock('../src/core/validation', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + // 带 requiredKeys 的调用即逐字段校验克隆一个布局项。 + readPlainDataObject: ((value, options) => { + if (options.requiredKeys) cloneCounter.items++ + return actual.readPlainDataObject(value, options) + }) as typeof actual.readPlainDataObject, + } +}) + +import { createLayoutEngine, defaultInternalConfig } from '../src/core/layout-engine' +import { verticalCompactor } from '../src/core/compactors' +import { cloneLayout, isSealedLayout, sealLayout } from '../src/helpers/common' + +import type { InternalEffectiveConfig } from '../src/core/layout-engine' +import type { Layout } from '../src/helpers/types' + +const ITEMS = 60 + +function config(): InternalEffectiveConfig { + return { ...defaultInternalConfig, cols: 12, compactor: verticalCompactor, collisionMode: 'push' } +} + +function grid(): Layout { + return Array.from({ length: ITEMS }, (_, index) => ({ + i: `n${index}`, + x: (index % 6) * 2, + y: Math.floor(index / 6) * 2, + w: 2, + h: 2, + meta: { tags: ['a', { deep: index }] }, + })) +} + +function isDeepFrozen(value: unknown): boolean { + if (typeof value !== 'object' || value === null) return true + if (!Object.isFrozen(value)) return false + return Object.values(value).every(isDeepFrozen) +} + +function isAnyFrozen(value: unknown): boolean { + if (typeof value !== 'object' || value === null) return false + if (Object.isFrozen(value) && !(Array.isArray(value) && value.every(v => typeof v === 'string'))) + return true + return Object.values(value).some(isAnyFrozen) +} + +beforeEach(() => { + cloneCounter.items = 0 +}) + +describe('引擎封存的布局复制时不再重复校验', () => { + it('一次拖拽步进只校验它新产生的布局', () => { + const engine = createLayoutEngine(grid(), config()) + const started = engine.beginInteraction({ type: 'drag', id: 'n0' }) + if (started.status !== 'accepted') throw new Error('drag rejected') + + const perStep: number[] = [] + for (let step = 1; step <= 4; step++) { + cloneCounter.items = 0 + const evaluation = engine.evaluateInteraction(started.session, { + type: 'drag', + x: step % 3, + y: step, + }) + expect(evaluation.result.status).toBe('accepted') + perStep.push(cloneCounter.items) + } + + // 修复前每步 14 次整表校验克隆(外加 1 次候选项)。封存后,已提交布局、会话基线、工作副本与 + // 评估结果的复制不再校验;剩余 9 次均发生在压缩器边界等可变工作副本上,仍需完整校验。 + for (const count of perStep) expect(count).toBeLessThanOrEqual(ITEMS * 9 + 1) + }) + + it('快速复制与校验克隆结果相同,且独立、可修改', () => { + const engine = createLayoutEngine(grid(), config()) + const evaluation = engine.evaluate({ type: 'move', id: 'n0', x: 1, y: 0 }) + engine.confirm(evaluation) + const copy = engine.rollback(engine.evaluate({ type: 'move', id: 'n1', x: 5, y: 0 })) + + expect(isSealedLayout(copy)).toBe(false) + expect(isAnyFrozen(copy)).toBe(false) + expect(copy).toEqual(cloneLayout(evaluation.result.layout)) + expect(copy.map(item => Object.keys(item))).toEqual( + evaluation.result.layout.map(item => Object.keys(item)), + ) + const meta = (copy[0] as unknown as { meta: { tags: unknown[] } }).meta + meta.tags.push('mutated') + const again = engine.rollback(engine.evaluate({ type: 'move', id: 'n1', x: 5, y: 0 })) + expect((again[0] as unknown as { meta: { tags: unknown[] } }).meta.tags).toHaveLength(2) + }) + + it('被标记的布局总是深度冻结,其快速复制与校验克隆一致', () => { + const metadata = Object.create(null) as Record + Object.defineProperty(metadata, '__proto__', { + value: { nested: [1, 'x'] }, + enumerable: true, + configurable: true, + writable: true, + }) + const source = [ + { i: 1, x: -0, y: 0, w: 1, h: 1, resizeHandles: ['se', 'n'], custom: metadata }, + { i: 'b', x: 2, y: 3, w: 2, h: 1, static: true, zIndex: -2, maxW: Infinity }, + ] as unknown as Layout + const sealed = sealLayout(cloneLayout(source)) + expect(isSealedLayout(sealed)).toBe(true) + expect(isDeepFrozen(sealed)).toBe(true) + + const fast = cloneLayout(sealed) + const slow = cloneLayout(source) + expect(fast).toEqual(slow) + expect(fast.map(item => Object.keys(item))).toEqual(slow.map(item => Object.keys(item))) + expect(Object.is(fast[0].x, 0)).toBe(true) + expect(Object.isFrozen(fast[0].resizeHandles)).toBe(true) + expect(fast[0].resizeHandles).not.toBe(sealed[0].resizeHandles) + const custom = (fast[0] as unknown as { custom: Record }).custom + expect(Object.getPrototypeOf(custom)).toBeNull() + expect(Object.keys(custom)).toEqual(['__proto__']) + expect(Object.isFrozen(custom)).toBe(false) + expect(isSealedLayout(fast)).toBe(false) + }) + + it('从不封存或冻结外部布局与对外载荷', () => { + const external = grid() + const engine = createLayoutEngine(external, config()) + expect(isSealedLayout(external)).toBe(false) + expect(isAnyFrozen(external)).toBe(false) + + const evaluation = engine.evaluate({ type: 'move', id: 'n0', x: 1, y: 0 }) + const confirmed = engine.confirm(evaluation) + const started = engine.beginInteraction({ type: 'drag', id: 'n2' }) + if (started.status !== 'accepted') throw new Error('drag rejected') + const step = engine.evaluateInteraction(started.session, { type: 'drag', x: 0, y: 3 }) + engine.confirm(step) + engine.closeInteraction(started.session) + const merged = engine.mergeExternalMetadata(external) + const replaced = engine.replaceExternal(grid(), config()) + + for (const layout of [ + external, + evaluation.result.layout, + evaluation.result.previousLayout, + confirmed.layout, + started.session.baseLayout, + step.result.layout, + step.result.previousLayout, + merged, + replaced.layout, + replaced.previousLayout, + ]) { + expect(isSealedLayout(layout)).toBe(false) + expect(isAnyFrozen(layout)).toBe(false) + } + // 外部数组即使被库原样封存函数处理过的副本引用,也不会获得标记。 + expect(isDeepFrozen(external)).toBe(false) + }) +}) From ab6f587b3979f2c78c482f631cd67619e1215ecb Mon Sep 17 00:00:00 2001 From: zahid emre zorbaz Date: Tue, 6 Oct 2026 23:02:55 +0300 Subject: [PATCH 3/9] perf(grid-item): keep the style object when the computed style is unchanged Every commit and drag step bumps positionStyleRevision and each GridItem recomputes its style. A new object with the same values still triggered a re-render, so every item re-rendered whenever any item moved. GridItem now assigns the style only when sameStyle finds a difference; GridLayout keeps the root height object and renderedLayoutStyle the same way. --- .changeset/keep-equal-item-styles.md | 5 + src/components/grid-item.vue | 19 +- src/components/grid-layout.vue | 28 ++- src/helpers/style-equality.ts | 15 ++ tests/style-equality.spec.tsx | 289 +++++++++++++++++++++++++++ 5 files changed, 339 insertions(+), 17 deletions(-) create mode 100644 .changeset/keep-equal-item-styles.md create mode 100644 src/helpers/style-equality.ts create mode 100644 tests/style-equality.spec.tsx diff --git a/.changeset/keep-equal-item-styles.md b/.changeset/keep-equal-item-styles.md new file mode 100644 index 0000000..844a13c --- /dev/null +++ b/.changeset/keep-equal-item-styles.md @@ -0,0 +1,5 @@ +--- +'grid-layout-plus': patch +--- + +Keep the previous style object when a GridItem's computed position style is unchanged, so moving one item no longer re-renders every item. diff --git a/src/components/grid-item.vue b/src/components/grid-item.vue index 2eb9475..657d66c 100644 --- a/src/components/grid-item.vue +++ b/src/components/grid-item.vue @@ -34,6 +34,7 @@ import { createCoreData, getControlPosition } from '../helpers/draggable' import { getColsFromBreakpoint } from '../helpers/responsive' import { getDocumentDir } from '../helpers/dom' import { snapshotInteractOption, snapshotInteractSelector } from '../helpers/interact-options' +import { sameStyle } from '../helpers/style-equality' import { gridToPixelRect, pixelSizeToGridSize, pointerToGridPosition } from '../core/utils' import interact from 'interactjs' @@ -444,7 +445,7 @@ watch( return } state.registered = false - state.style = {} + assignStyle({}) layout.updateItem(instance, previousId) }, ) @@ -643,15 +644,21 @@ watch( }, ) +// 内容相同时保留原样式对象:父布局每次提交都会让所有 GridItem 重新求值样式, +// 若为盒子未变的元素写入新对象,响应式会触发一次无意义的重新渲染。 +function assignStyle(next: Record) { + if (!sameStyle(state.style, next)) state.style = next +} + function createStyle() { if (!state.registered) { - state.style = {} + assignStyle({}) return } // 非装饰且未处于拖拽或缩放状态的元素直接使用父布局原子提交的样式;只有交互暂态和占位项在本地重新计算。 if (!props.decorative && !state.isDragging && !state.isResizing) { - state.style = { ...layout.getPositionStyle(props.i) } + assignStyle({ ...layout.getPositionStyle(props.i) }) return } const renderedWidth = resolvedContainerWidth() @@ -666,7 +673,7 @@ function createStyle() { !Number.isSafeInteger(effectiveH.value) || effectiveH.value <= 0)) ) { - state.style = {} + assignStyle({}) return } @@ -724,7 +731,7 @@ function createStyle() { value => typeof value === 'number' && Number.isFinite(value), ) ) { - state.style = props.decorative ? {} : { ...layout.getPositionStyle(props.i) } + assignStyle(props.decorative ? {} : { ...layout.getPositionStyle(props.i) }) return } const direction = renderRtl.value ? 'rtl' : 'ltr' @@ -757,7 +764,7 @@ function createStyle() { } } - state.style = style + assignStyle(style) } function resetInteractionState(type?: 'drag' | 'resize'): void { diff --git a/src/components/grid-layout.vue b/src/components/grid-layout.vue index 5a96f47..d0a3617 100644 --- a/src/components/grid-layout.vue +++ b/src/components/grid-layout.vue @@ -25,6 +25,7 @@ import GridItem from './grid-item.vue' import { createEventEmitter } from '@vexip-ui/utils' import { useContainerWidth } from '../composables/useContainerWidth' import { EMITTER_KEY, LAYOUT_KEY, bottom, cloneLayout, getLayoutItem } from '../helpers/common' +import { sameStyle } from '../helpers/style-equality' import { getDocumentDir } from '../helpers/dom' import { cloneResponsiveLayouts, @@ -287,7 +288,7 @@ const effectiveConfig = computed(() => ({ const state = reactive({ width: initialWidth as number | null, - mergedStyle: {}, + mergedStyle: {} as { height?: string }, lastLayoutLength: 0, isDragging: false, suppressTransitions: true, @@ -393,13 +394,17 @@ function resolveEngineConfig( let engineConfig = resolveEngineConfig() const appliedEngineConfig = shallowRef(engineConfig) -const renderedLayoutStyle = computed(() => ({ - ...state.mergedStyle, - '--vgl-layout-interaction-z-index': - appliedEngineConfig.value.collisionMode === 'overlap' - ? '0' - : String(currentLayout.value.length), -})) +// 内容相同时沿用上一次的样式对象,根节点样式不因布局提交而产生新的响应式变化。 +const renderedLayoutStyle = computed>(previous => { + const next = { + ...state.mergedStyle, + '--vgl-layout-interaction-z-index': + appliedEngineConfig.value.collisionMode === 'overlap' + ? '0' + : String(currentLayout.value.length), + } + return previous && sameStyle(previous, next) ? previous : next +}) const canNormalizeInitialLayout = !responsiveMode.value || initialProvisionalBreakpoint !== null // 响应式宽度未解析时暂不收紧横向边界,待选出断点和列数后再做完整规范化。 const initialEngine = canNormalizeInitialLayout @@ -1638,9 +1643,10 @@ function layoutUpdate() { } function updateHeight() { - state.mergedStyle = { - height: containerHeight(), - } + const height = containerHeight() + // 高度未变时保留原对象:每次提交都会调用此处,写入新对象会让根节点样式重新求值。 + if ('height' in state.mergedStyle && state.mergedStyle.height === height) return + state.mergedStyle = { height } } function containerHeight() { diff --git a/src/helpers/style-equality.ts b/src/helpers/style-equality.ts new file mode 100644 index 0000000..aef4924 --- /dev/null +++ b/src/helpers/style-equality.ts @@ -0,0 +1,15 @@ +/** + * 比较两个内联样式映射是否相同:键集合相同且逐键字符串值相等(未设置的值为 `undefined`)。 + * 用于在内容未变时保留原样式对象,避免响应式触发无意义的重新渲染。 + */ +export function sameStyle( + a: Readonly>, + b: Readonly>, +): boolean { + const keys = Object.keys(a) + if (keys.length !== Object.keys(b).length) return false + for (const key of keys) { + if (!Object.hasOwn(b, key) || a[key] !== b[key]) return false + } + return true +} diff --git a/tests/style-equality.spec.tsx b/tests/style-equality.spec.tsx new file mode 100644 index 0000000..963ae3e --- /dev/null +++ b/tests/style-equality.spec.tsx @@ -0,0 +1,289 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent, h, nextTick, ref } from 'vue' + +import { GridItem, GridLayout } from '../src' +import { sameStyle } from '../src/helpers/style-equality' + +import type { ComponentPublicInstance, PropType } from 'vue' +import type { Layout, LayoutItem } from '../src/helpers/types' + +const interactMock = vi.hoisted(() => { + const interactables = new Map() + const interact = vi.fn((element: Element) => { + const listeners = new Map void>() + const instance: Record = { listeners } + + instance.draggable = vi.fn(() => instance) + instance.resizable = vi.fn(() => instance) + instance.styleCursor = vi.fn(() => instance) + instance.unset = vi.fn(() => instance) + instance.on = vi.fn((types: string, listener: (event: any) => void) => { + for (const type of types.split(' ')) listeners.set(type, listener) + return instance + }) + + interactables.set(element, instance) + return instance + }) + + Object.assign(interact, { modifiers: { aspectRatio: vi.fn(() => ({})) } }) + + return { interact, interactables } +}) + +vi.mock('interactjs', () => ({ default: interactMock.interact })) + +// happy-dom 没有布局计算,`offsetParent` 恒为 null;GridLayout 只在手动放置的 GridItem +// 的包含块为布局根节点时才注册它,浏览器中由 `.vgl-layout { position: relative }` 保证。 +const offsetParentDescriptor = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'offsetParent', +) + +beforeEach(() => { + interactMock.interact.mockClear() + interactMock.interactables.clear() + Object.defineProperty(HTMLElement.prototype, 'offsetParent', { + configurable: true, + get(this: HTMLElement) { + return this.parentElement?.closest('.vgl-layout') ?? null + }, + }) +}) + +afterEach(() => { + if (offsetParentDescriptor) { + Object.defineProperty(HTMLElement.prototype, 'offsetParent', offsetParentDescriptor) + } + document.body.innerHTML = '' +}) + +describe('sameStyle', () => { + it('比较键和值,而不是对象引用', () => { + expect(sameStyle({ a: '1', b: '2' }, { b: '2', a: '1' })).toBe(true) + expect(sameStyle({ a: '1' }, { a: '1', b: '2' })).toBe(false) + expect(sameStyle({ a: '1' }, { a: '2' })).toBe(false) + expect(sameStyle({}, {})).toBe(true) + expect(sameStyle({ height: undefined }, { height: undefined })).toBe(true) + expect(sameStyle({ height: undefined }, { width: undefined })).toBe(false) + }) +}) + +/** + * 通过 `updated` 钩子按元素 id 记录 GridItem 的重新渲染。不使用插槽计数:要回答的是 GridItem + * 本身是否重新渲染,只有它自己的生命周期钩子能回答。 + */ +function createRenderProbe() { + const updates: Record = {} + const mixin = { + updated(this: ComponentPublicInstance) { + if (this.$.type !== GridItem) return + const id = String(this.$props.i) + updates[id] = (updates[id] ?? 0) + 1 + }, + } + return { updates, mixin } +} + +function createRect(left: number, top: number, right: number, bottom: number): DOMRect { + return { + bottom, + height: bottom - top, + left, + right, + top, + width: right - left, + x: left, + y: top, + toJSON: () => ({}), + } +} + +function dragEvent(type: string, target: HTMLElement, clientX: number, clientY: number) { + const event = new MouseEvent(type, { clientX, clientY }) + Object.defineProperty(event, 'target', { configurable: true, value: target }) + return event +} + +/** 拖拽候选合并到下一动画帧处理;等待该帧与 Vue 队列。 */ +async function flush() { + await new Promise(resolve => requestAnimationFrame(() => resolve())) + for (let index = 0; index < 4; index++) await nextTick() +} + +/** 像浏览器一样,让 `element` 的包围盒从 `originX` 起跟随指针。 */ +function trackPointer(element: HTMLElement, left: number, originX: number) { + let pointerX = originX + Object.defineProperty(element, 'getBoundingClientRect', { + configurable: true, + value: () => createRect(left + pointerX - originX, 0, left + pointerX - originX + 200, 70), + }) + return (x: number) => (pointerX = x) +} + +function itemElement(wrapper: ReturnType, id: string) { + return wrapper + .findAll('.vgl-item') + .find( + element => + !element.classes().includes('vgl-item--placeholder') && element.text().trim() === id, + )! +} + +/** + * 每个单元格一个组件,由它放置自己的 GridItem(仪表盘中常见的写法)。`$stable` 按编译模板的方式 + * 标记插槽,因此 GridItem 只会因自身响应式状态重新渲染,不会因单元格或布局重新渲染而渲染。 + * 单元格只接收 id:提交后发出的布局携带新的元素对象,插槽若读取这类对象, + * 就会因库外部的原因让 GridItem 重新渲染。 + */ +const Cell = defineComponent({ + props: { id: { type: [String, Number] as PropType, required: true } }, + setup: props => () => + h(GridItem, { i: props.id }, { default: () => h('span', String(props.id)), $stable: true }), +}) + +async function mountGrid(layout: Layout) { + const probe = createRenderProbe() + const model = ref(layout) + + const host = () => + h( + GridLayout, + { + layout: model.value, + 'onUpdate:layout': (next: Layout) => (model.value = next), + width: 1200, + colNum: 12, + rowHeight: 30, + isResizable: false, + }, + { default: () => model.value.map(item => h(Cell, { key: item.i, id: item.i })) }, + ) + + const wrapper = mount(host, { attachTo: document.body, global: { mixins: [probe.mixin] } }) + await flush() + + const root = wrapper.find('.vgl-layout').element + Object.defineProperty(root, 'getBoundingClientRect', { + configurable: true, + value: () => createRect(0, 0, 1200, 600), + }) + + return { wrapper, model, ...probe } +} + +function delta(after: Record, before: Record, id: string) { + return (after[id] ?? 0) - (before[id] ?? 0) +} + +const twoItems = (): Layout => [ + { i: 'a', x: 0, y: 0, w: 2, h: 2 }, + { i: 'b', x: 4, y: 0, w: 2, h: 2 }, +] + +describe('GridItem 样式写入', () => { + it('相邻元素移动时,盒子未变的元素不重新渲染', async () => { + const { wrapper, model, updates } = await mountGrid(twoItems()) + const before = { ...updates } + + model.value = [ + { i: 'a', x: 0, y: 0, w: 2, h: 2 }, + { i: 'b', x: 6, y: 0, w: 2, h: 2 }, + ] + await flush() + + expect(itemElement(wrapper, 'b').attributes('style')).toContain('translate3d(605px') + expect(delta(updates, before, 'a')).toBe(0) + expect(delta(updates, before, 'b')).toBeGreaterThan(0) + wrapper.unmount() + }) + + it('相邻元素拖拽步进期间,盒子未变的元素不重新渲染', async () => { + const { wrapper, model, updates } = await mountGrid(twoItems()) + const b = itemElement(wrapper, 'b').element + const moveTo = trackPointer(b, 403, 450) + const listener = interactMock.interactables.get(b).listeners.get('dragstart')! + + listener(dragEvent('dragstart', b, 450, 20)) + moveTo(460) + listener(dragEvent('dragmove', b, 460, 20)) + await flush() + const before = { ...updates } + + // 同一列内的像素级步进,以及跨入后续列的步进。 + for (const x of [480, 500, 520, 560, 600, 700]) { + moveTo(x) + listener(dragEvent('dragmove', b, x, 20)) + await flush() + } + + expect(itemElement(wrapper, 'b').classes()).toContain('vgl-item--dragging') + expect(model.value.find(item => item.i === 'b')!.x).toBeGreaterThan(4) + expect(delta(updates, before, 'a')).toBe(0) + expect(delta(updates, before, 'b')).toBeGreaterThan(0) + + listener(dragEvent('dragend', b, 700, 20)) + await flush() + wrapper.unmount() + }) + + it('放下及随后的压缩不会让盒子未变的元素重新渲染', async () => { + // `c` 位于 `b` 下方;拖走 `b` 后纵向压缩器把 `c` 上提。`a` 的盒子不变。 + const { wrapper, model, updates } = await mountGrid([ + { i: 'a', x: 0, y: 0, w: 2, h: 2 }, + { i: 'b', x: 4, y: 0, w: 2, h: 2 }, + { i: 'c', x: 4, y: 2, w: 2, h: 2 }, + ]) + const b = itemElement(wrapper, 'b').element + const moveTo = trackPointer(b, 403, 450) + const listener = interactMock.interactables.get(b).listeners.get('dragstart')! + const before = { ...updates } + + listener(dragEvent('dragstart', b, 450, 20)) + for (const x of [470, 600, 750, 850]) { + moveTo(x) + listener(dragEvent('dragmove', b, x, 20)) + await flush() + } + listener(dragEvent('dragend', b, 850, 20)) + await flush() + + const byId = Object.fromEntries(model.value.map(item => [item.i, [item.x, item.y]])) + expect(byId.b![0]).toBeGreaterThan(5) + expect(byId.c).toEqual([4, 0]) + expect(byId.a).toEqual([0, 0]) + expect(delta(updates, before, 'a')).toBe(0) + expect(delta(updates, before, 'c')).toBeGreaterThan(0) + wrapper.unmount() + }) + + it('容器高度不变时不重写布局根节点样式', async () => { + const { wrapper, model } = await mountGrid(twoItems()) + const root = wrapper.find('.vgl-layout') + // GridLayout 最近一次渲染中绑定到根元素的样式对象。 + const layoutInstance = wrapper.findComponent(GridLayout).vm.$ + const rootStyle = () => layoutInstance.subTree.props?.style + const height = root.element.style.height + const style = rootStyle() + + model.value = [ + { i: 'a', x: 0, y: 0, w: 2, h: 2 }, + { i: 'b', x: 6, y: 0, w: 2, h: 2 }, + ] + await flush() + + expect(root.element.style.height).toBe(height) + expect(rootStyle()).toBe(style) + + model.value = [ + { i: 'a', x: 0, y: 0, w: 2, h: 2 }, + { i: 'b', x: 6, y: 0, w: 2, h: 4 }, + ] + await flush() + + expect(root.element.style.height).not.toBe(height) + expect(rootStyle()).not.toBe(style) + wrapper.unmount() + }) +}) From f56f0cb18731915debe89557ef3e10d71ee4f2fc Mon Sep 17 00:00:00 2001 From: zahid emre zorbaz Date: Tue, 6 Oct 2026 23:03:02 +0300 Subject: [PATCH 4/9] fix(grid-layout): validate a same-tick layout with the incoming colNum The layout watcher runs before the config watcher, so a layout that arrived together with a new colNum was validated with the old column count. Going from a narrow to a wide grid (colNum 1 -> 12) emitted a transient invalid-layout and one invalid-registration (missing-id) per new item, then recovered a tick later. In non-responsive mode the layout is now observed with the incoming colNum (restored when the layout is not accepted), and snapshotObservedLayout validates with the same next config acceptExternalLayout applies. A layout that does not fit the new colNum is still rejected. --- .changeset/same-tick-col-num.md | 5 + src/components/grid-layout.vue | 16 +- src/components/grid-layout/use-layout-sync.ts | 3 +- tests/col-num-transition.spec.tsx | 168 ++++++++++++++++++ 4 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 .changeset/same-tick-col-num.md create mode 100644 tests/col-num-transition.spec.tsx diff --git a/.changeset/same-tick-col-num.md b/.changeset/same-tick-col-num.md new file mode 100644 index 0000000..5fe8e1d --- /dev/null +++ b/.changeset/same-tick-col-num.md @@ -0,0 +1,5 @@ +--- +'grid-layout-plus': patch +--- + +Validate a layout that changes in the same tick as `colNum` against the incoming column count, avoiding transient `invalid-layout` and `invalid-registration` errors. diff --git a/src/components/grid-layout.vue b/src/components/grid-layout.vue index d0a3617..5788ca6 100644 --- a/src/components/grid-layout.vue +++ b/src/components/grid-layout.vue @@ -971,7 +971,21 @@ const layoutSync = useGridLayoutSync({ nextRevision, }) layoutSyncRef.current = layoutSync -const observeLayoutProp = layoutSync.observeLayoutProp +// 非响应式模式下 colNum 与 layout 可能在同一 tick 变化,而 layout watcher 先于配置 +// watcher 运行;因此用随 layout 一起到达的 colNum 观察新布局,布局未被接纳时恢复原列数。 +function observeLayoutProp(): void { + if (responsiveMode.value || currentColNum.value === effectiveColNum.value) { + layoutSync.observeLayoutProp() + return + } + const previousColNum = currentColNum.value + currentColNum.value = effectiveColNum.value + try { + layoutSync.observeLayoutProp() + } finally { + if (engineConfig.cols !== currentColNum.value) currentColNum.value = previousColNum + } +} function replaceEngineLayout(layout: ReadonlyLayout, config = resolveEngineConfig()): boolean { const hadActiveInteraction = interaction.hasActive() diff --git a/src/components/grid-layout/use-layout-sync.ts b/src/components/grid-layout/use-layout-sync.ts index 0ca3786..6ca234e 100644 --- a/src/components/grid-layout/use-layout-sync.ts +++ b/src/components/grid-layout/use-layout-sync.ts @@ -118,7 +118,8 @@ export function useGridLayoutSync( const transactionController = options.getTransactionController() const pending = transactionController.getPending() try { - const config = pending?.evaluation.nextConfig ?? options.getEngineConfig() + // 与 acceptExternalLayout 使用同一份下一配置校验,同 tick 的配置变化不被旧配置误判。 + const config = pending?.evaluation.nextConfig ?? options.resolveEngineConfig() return options.isResponsive() && options.state.width === null ? snapshotUnresolvedLayout(options.getLayout(), config) : snapshotStrictLayout(options.getLayout(), config) diff --git a/tests/col-num-transition.spec.tsx b/tests/col-num-transition.spec.tsx new file mode 100644 index 0000000..6dd0e18 --- /dev/null +++ b/tests/col-num-transition.spec.tsx @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent, h, nextTick, ref } from 'vue' + +import { GridItem, GridLayout } from '../src' + +import type { PropType } from 'vue' +import type { GridLayoutRuntimeError } from '../src/composables/useGridLayout' +import type { Layout, LayoutItem } from '../src/helpers/types' + +vi.mock('interactjs', () => { + const interact = vi.fn(() => { + const instance: Record = {} + for (const name of ['draggable', 'resizable', 'styleCursor', 'unset', 'on']) { + instance[name] = vi.fn(() => instance) + } + return instance + }) + Object.assign(interact, { modifiers: { aspectRatio: vi.fn(() => ({})) } }) + return { default: interact } +}) + +// happy-dom 没有布局计算,`offsetParent` 恒为 null;GridLayout 只在手动放置的 GridItem +// 的包含块为布局根节点时才注册它(见 tests/style-equality.spec.tsx)。 +const offsetParentDescriptor = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'offsetParent', +) + +beforeEach(() => { + Object.defineProperty(HTMLElement.prototype, 'offsetParent', { + configurable: true, + get(this: HTMLElement) { + return this.parentElement?.closest('.vgl-layout') ?? null + }, + }) +}) + +afterEach(() => { + if (offsetParentDescriptor) { + Object.defineProperty(HTMLElement.prototype, 'offsetParent', offsetParentDescriptor) + } + document.body.innerHTML = '' +}) + +async function flush() { + await new Promise(resolve => requestAnimationFrame(() => resolve())) + for (let index = 0; index < 4; index++) await nextTick() +} + +/** 每个单元格一个组件,由它放置自己的 GridItem(仪表盘中常见的写法)。 */ +const Cell = defineComponent({ + props: { id: { type: [String, Number] as PropType, required: true } }, + setup: props => () => + h(GridItem, { i: props.id }, { default: () => h('span', String(props.id)), $stable: true }), +}) + +/** 在一次赋值中同时切换 layout 与 colNum 的父组件,类似移动端/桌面端切换。 */ +async function mountGrid(layout: Layout, colNum: number) { + const view = ref({ layout, colNum }) + const errors: GridLayoutRuntimeError[] = [] + + const host = () => + h( + GridLayout, + { + layout: view.value.layout, + colNum: view.value.colNum, + 'onUpdate:layout': (next: Layout) => (view.value = { ...view.value, layout: next }), + onError: (error: GridLayoutRuntimeError) => errors.push(error), + width: 1200, + rowHeight: 30, + isDraggable: false, + isResizable: false, + }, + { default: () => view.value.layout.map(item => h(Cell, { key: item.i, id: item.i })) }, + ) + + const wrapper = mount(host, { attachTo: document.body }) + await flush() + return { wrapper, view, errors } +} + +function geometry(layout: Layout) { + return layout.map(({ i, x, y, w, h }) => ({ i, x, y, w, h })) +} + +function itemStyle(wrapper: ReturnType, id: string) { + return wrapper + .findAll('.vgl-item') + .find(element => element.text().trim() === id)! + .attributes('style') +} + +// 桌面端:12 列,两个半宽元素并排,另有仅桌面端存在的 `d`。 +const desktop = (): Layout => [ + { i: 'a', x: 0, y: 0, w: 6, h: 2 }, + { i: 'b', x: 6, y: 0, w: 6, h: 2 }, + { i: 'd', x: 0, y: 2, w: 12, h: 2 }, +] +// 移动端:1 列纵向堆叠,另有仅移动端存在的 `m`。 +const mobile = (): Layout => [ + { i: 'a', x: 0, y: 0, w: 1, h: 2 }, + { i: 'b', x: 0, y: 2, w: 1, h: 2 }, + { i: 'm', x: 0, y: 4, w: 1, h: 2 }, +] + +describe('colNum 与 layout 在同一 tick 变化', () => { + it('移动端到桌面端(1 -> 12)不报错并绘制新布局', async () => { + const { wrapper, view, errors } = await mountGrid(mobile(), 1) + expect(errors).toEqual([]) + + view.value = { layout: desktop(), colNum: 12 } + await flush() + + expect(errors).toEqual([]) + expect(geometry(view.value.layout)).toEqual(geometry(desktop())) + // 1200px、12 列、间距 10px、无内边距:列步长 605/6px,6 列宽的元素跨 595px。 + expect(itemStyle(wrapper, 'b')).toContain('translate3d(605px, 0px') + expect(itemStyle(wrapper, 'b')).toContain('width: 595px') + expect(itemStyle(wrapper, 'd')).toContain('translate3d(0px, 80px') + expect(itemStyle(wrapper, 'd')).toContain('width: 1200px') + wrapper.unmount() + }) + + it('桌面端到移动端(12 -> 1)不报错并绘制新布局', async () => { + const { wrapper, view, errors } = await mountGrid(desktop(), 12) + expect(errors).toEqual([]) + + view.value = { layout: mobile(), colNum: 1 } + await flush() + + expect(errors).toEqual([]) + expect(geometry(view.value.layout)).toEqual(geometry(mobile())) + // 单列占满 1200px;行步长为 30px 加 10px 间距。 + expect(itemStyle(wrapper, 'b')).toContain('translate3d(0px, 80px') + expect(itemStyle(wrapper, 'm')).toContain('translate3d(0px, 160px') + expect(itemStyle(wrapper, 'm')).toContain('width: 1200px') + wrapper.unmount() + }) + + it('不符合新 colNum 的布局仍被拒绝', async () => { + const { wrapper, view, errors } = await mountGrid(mobile(), 1) + + // `b` 结束于第 14 列,而总共只有 12 列。 + view.value = { + layout: [ + { i: 'a', x: 0, y: 0, w: 6, h: 2 }, + { i: 'b', x: 8, y: 0, w: 6, h: 2 }, + ], + colNum: 12, + } + await flush() + + expect(errors.some(error => error.code === 'invalid-layout')).toBe(true) + wrapper.unmount() + }) + + it('不符合未变 colNum 的布局仍被拒绝', async () => { + const { wrapper, view, errors } = await mountGrid(desktop(), 12) + + view.value = { ...view.value, layout: [{ i: 'a', x: 10, y: 0, w: 6, h: 2 }] } + await flush() + + expect(errors.some(error => error.code === 'invalid-layout')).toBe(true) + wrapper.unmount() + }) +}) From 0de682868b6f75de51a49bc812fb14c97c174d77 Mon Sep 17 00:00:00 2001 From: zahid emre zorbaz Date: Tue, 6 Oct 2026 23:03:28 +0300 Subject: [PATCH 5/9] perf(grid-layout): coalesce registry validation and index layout ids Every GridItem onUpdated validated the whole registry synchronously and each registration queued another full pass; every pass searched the deep reactive currentLayout linearly per item. With n items re-rendering this was O(n^3) proxy reads during a flush that re-rendered all items. - the registry keeps at most one deferred pass, queued with queuePostFlushCb so it runs at the end of the current flush; synchronous passes consume it - hasLayoutItem, the injected GridItem lookup and the resize constraint check use an id index built from the raw layout once per replacement - id-change cancellation and the interaction/layout-sync passes stay synchronous --- .changeset/coalesce-registry-validation.md | 5 + src/components/grid-layout.vue | 29 +- src/components/grid-layout/item-registry.ts | 25 +- .../grid-layout/position-style-controller.ts | 6 +- tests/registry-validation.spec.tsx | 365 ++++++++++++++++++ 5 files changed, 418 insertions(+), 12 deletions(-) create mode 100644 .changeset/coalesce-registry-validation.md create mode 100644 tests/registry-validation.spec.tsx diff --git a/.changeset/coalesce-registry-validation.md b/.changeset/coalesce-registry-validation.md new file mode 100644 index 0000000..cf4406b --- /dev/null +++ b/.changeset/coalesce-registry-validation.md @@ -0,0 +1,5 @@ +--- +'grid-layout-plus': patch +--- + +Coalesce GridItem registry validation into one pass per flush and look up layout items by id, removing a cubic slowdown with many items. diff --git a/src/components/grid-layout.vue b/src/components/grid-layout.vue index 5788ca6..deea02c 100644 --- a/src/components/grid-layout.vue +++ b/src/components/grid-layout.vue @@ -13,6 +13,7 @@ import { onBeforeUnmount, onMounted, provide, + queuePostFlushCb, reactive, ref, shallowRef, @@ -322,6 +323,26 @@ const registrationEpisodes = new WeakMap() const currentLayout = ref(cloneLayout(props.layout)) let committedLayout = cloneLayout(currentLayout.value) +// 按 id 查找(注册表成员校验、GridItem 读取自身布局项)走原始数组:在深层响应式代理上线性查找, +// 每个元素和字段都会触发一次代理拦截,一轮校验即 O(n²)。currentLayout 只会被整体替换、从不原地修改, +// 因此每次替换只需重建一次索引。 +let currentLayoutIndexSource: ReadonlyLayout | null = null +let currentLayoutIndex = new Map() + +function getCurrentLayoutIndex(): ReadonlyMap { + // 读取 `.value` 使 computed 调用方仍订阅布局替换。 + const layout = toRaw(currentLayout.value) + if (layout !== currentLayoutIndexSource) { + currentLayoutIndex = new Map() + // 与 getLayoutItem 一致:同一 id 取第一个元素。 + for (const item of layout) { + if (!currentLayoutIndex.has(item.i)) currentLayoutIndex.set(item.i, item) + } + currentLayoutIndexSource = layout + } + return currentLayoutIndex +} + const itemZIndexRanks = computed(() => { const ordered = currentLayout.value .map((item, index) => ({ id: item.i, index, zIndex: item.zIndex ?? 0 })) @@ -705,14 +726,16 @@ const itemRegistry = createGridItemRegistry({ registrationEpisodes, isUnavailable: () => disposing || sealedError !== null, getRoot: () => wrapper.value, - hasLayoutItem: id => getLayoutItem(currentLayout.value, id) !== undefined, + hasLayoutItem: id => getCurrentLayoutIndex().has(id), getActiveInteractionId: () => interaction.getActive()?.id ?? null, prepareActiveForTerminal, finishActiveForExternalUpdate: () => { finishInteraction('cancelled', 'external-update', { nativeEvent: null }) }, + // 在当前 flush 的 post 阶段末尾执行(排在本轮所有 updated/mounted 钩子之后), + // 校验结果触发的 watcher 仍在同一次 flush 内完成,与原先 onUpdated 内同步校验的时序一致。 scheduleValidation: callback => { - nextTick(() => runAsyncBoundary(callback)) + queuePostFlushCb(() => runAsyncBoundary(callback)) }, nextEvaluationId, emitError: error => emit('error', error), @@ -1562,7 +1585,7 @@ const exposed = { defineExpose(exposed) function getInjectedLayoutItem(id: LayoutItem['i']): ReadonlyLayoutItem | undefined { - const item = getLayoutItem(currentLayout.value, id) + const item = getCurrentLayoutIndex().get(id) return item ? cloneLayout([item])[0] : undefined } diff --git a/src/components/grid-layout/item-registry.ts b/src/components/grid-layout/item-registry.ts index ad637bb..bf2fb69 100644 --- a/src/components/grid-layout/item-registry.ts +++ b/src/components/grid-layout/item-registry.ts @@ -56,7 +56,20 @@ export function createGridItemRegistry(options: GridItemRegistryOptions): GridIt options.finishActiveForExternalUpdate() } + // 每个注册表最多排队一轮延迟校验。一轮校验会遍历全部注册项,同一次 flush 中的注册与重新渲染共用它; + // 同步校验会消费掉尚未执行的延迟校验。 + let validationPending = false + + function requestValidation(): void { + if (validationPending) return + validationPending = true + options.scheduleValidation(() => { + if (validationPending) validate() + }) + } + function validate(): void { + validationPending = false if (options.isUnavailable()) return const root = options.getRoot() if (!root) return @@ -106,7 +119,7 @@ export function createGridItemRegistry(options: GridItemRegistryOptions): GridIt return } registeredItems.add(item) - options.scheduleValidation(validate) + requestValidation() } function decrease(item: GridItemRegistration): void { @@ -119,7 +132,7 @@ export function createGridItemRegistry(options: GridItemRegistryOptions): GridIt registeredItems.delete(item) if (itemInstances.get(item.i) === item) itemInstances.delete(item.i) registrationEpisodes.delete(item) - if (!options.isUnavailable()) options.scheduleValidation(validate) + if (!options.isUnavailable()) requestValidation() } function update(item: GridItemRegistration, previousId: LayoutItem['i']): void { @@ -127,14 +140,12 @@ export function createGridItemRegistry(options: GridItemRegistryOptions): GridIt item.state.registered = false return } + // 取消旧 id 的交互保持同步;归属校验本身延迟并合并, + // 因此一次让所有 GridItem 重新渲染的 flush 只遍历注册表一次。 if (!Object.is(previousId, item.i) && options.getActiveInteractionId() === previousId) { cancelActiveInteraction() } - if (Object.is(previousId, item.i)) { - validate() - return - } - options.scheduleValidation(validate) + requestValidation() } return { diff --git a/src/components/grid-layout/position-style-controller.ts b/src/components/grid-layout/position-style-controller.ts index 7042e43..ecf951b 100644 --- a/src/components/grid-layout/position-style-controller.ts +++ b/src/components/grid-layout/position-style-controller.ts @@ -8,7 +8,6 @@ import { GridLayoutValidationError } from '../../core/errors' import { validatePositionGeometry, validatePositionStyleResult } from '../../core/position-style' import { gridToPixelRect, isDerivedGeometryError } from '../../core/utils' -import { getLayoutItem } from '../../helpers/common' import type { GridLayoutRuntimeError } from '../../composables/useGridLayout' import type { InternalEffectiveConfig } from '../../core/layout-engine' @@ -130,7 +129,10 @@ export function createGridPositionStyleController( const useRtl = options.getDirection() === 'rtl' const ranks = calculateZIndexRanks(layout) const styles = new Map>>() + // 本批布局的 id 索引,供下方尺寸约束校验使用;与 getLayoutItem 一致,同一 id 取第一个元素。 + const itemsById = new Map() for (const [index, item] of layout.entries()) { + if (!itemsById.has(item.i)) itemsById.set(item.i, item) const basePath = `layout[${index}].style` let geometry try { @@ -234,7 +236,7 @@ export function createGridPositionStyleController( try { for (const registeredItem of options.registeredItems) { if (!registeredItem.state.registered || !registeredItem.state.resizable) continue - const item = getLayoutItem(layout, registeredItem.i) + const item = itemsById.get(registeredItem.i) if (!item || item.static) continue const maximumW = Math.min(item.maxW ?? Infinity, config.cols - item.x) const maximumH = Math.min(item.maxH ?? Infinity, config.maxRows - item.y) diff --git a/tests/registry-validation.spec.tsx b/tests/registry-validation.spec.tsx new file mode 100644 index 0000000..cc2b359 --- /dev/null +++ b/tests/registry-validation.spec.tsx @@ -0,0 +1,365 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent, h, nextTick, ref } from 'vue' + +import { GridItem, GridLayout } from '../src' +import { createGridItemRegistry } from '../src/components/grid-layout/item-registry' + +import type { PropType } from 'vue' +import type { GridLayoutRuntimeError } from '../src/composables/useGridLayout' +import type { GridItemRegistration } from '../src/helpers/internal-types' +import type { Layout, LayoutItem } from '../src/helpers/types' + +/** + * 从外部统计 GridLayout 元素注册表的工作量:一轮完整校验是 `getRoot` 的唯一调用方, + * 并对每个已注册元素调用一次 `hasLayoutItem`。`getLayoutItem` 是布局的线性查找, + * 注册表与注入的元素查找都不应依赖它。 + */ +const counters = vi.hoisted(() => ({ passes: 0, membership: 0, linearSearches: 0 })) + +vi.mock('../src/components/grid-layout/item-registry', async importOriginal => { + const original = + await importOriginal() + return { + ...original, + createGridItemRegistry: (options: Parameters[0]) => + original.createGridItemRegistry({ + ...options, + getRoot: () => { + counters.passes += 1 + return options.getRoot() + }, + hasLayoutItem: id => { + counters.membership += 1 + return options.hasLayoutItem(id) + }, + }), + } +}) + +vi.mock('../src/helpers/common', async importOriginal => { + const original = await importOriginal() + return { + ...original, + getLayoutItem: ((layout: Layout, id: LayoutItem['i']) => { + counters.linearSearches += 1 + return original.getLayoutItem(layout, id) + }) as typeof original.getLayoutItem, + } +}) + +vi.mock('interactjs', () => { + const interact = vi.fn(() => { + const instance: Record = {} + for (const name of ['draggable', 'resizable', 'styleCursor', 'unset', 'on']) { + instance[name] = vi.fn(() => instance) + } + return instance + }) + Object.assign(interact, { modifiers: { aspectRatio: vi.fn(() => ({})) } }) + return { default: interact } +}) + +// happy-dom 没有布局计算,`offsetParent` 恒为 null;GridLayout 只在手动放置的 GridItem +// 的包含块为布局根节点时才注册它(见 tests/style-equality.spec.tsx)。 +const offsetParentDescriptor = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'offsetParent', +) + +beforeEach(() => { + Object.defineProperty(HTMLElement.prototype, 'offsetParent', { + configurable: true, + get(this: HTMLElement) { + return this.parentElement?.closest('.vgl-layout') ?? null + }, + }) +}) + +afterEach(() => { + if (offsetParentDescriptor) { + Object.defineProperty(HTMLElement.prototype, 'offsetParent', offsetParentDescriptor) + } + document.body.innerHTML = '' +}) + +function resetCounters() { + counters.passes = 0 + counters.membership = 0 + counters.linearSearches = 0 +} + +async function flush() { + await new Promise(resolve => requestAnimationFrame(() => resolve())) + for (let index = 0; index < 4; index++) await nextTick() +} + +/** 每个单元格一个组件,由它放置自己的 GridItem(仪表盘中常见的写法)。 */ +const Cell = defineComponent({ + props: { id: { type: [String, Number] as PropType, required: true } }, + setup: props => () => + h(GridItem, { i: props.id }, { default: () => h('span', String(props.id)), $stable: true }), +}) + +const size = 300 +// 桌面端:12 列,每行六个 2x2 单元格。 +const desktop = (): Layout => + Array.from({ length: size }, (_, k) => ({ + i: `c${k}`, + x: (k % 6) * 2, + y: Math.floor(k / 6) * 2, + w: 2, + h: 2, + })) +// 移动端:1 列,同样的单元格纵向堆叠。 +const mobile = (): Layout => + Array.from({ length: size }, (_, k) => ({ i: `c${k}`, x: 0, y: k * 2, w: 1, h: 2 })) + +async function mountGrid() { + const view = ref({ layout: desktop(), colNum: 12, width: 1200 }) + const errors: GridLayoutRuntimeError[] = [] + + const host = () => + h( + GridLayout, + { + layout: view.value.layout, + colNum: view.value.colNum, + width: view.value.width, + 'onUpdate:layout': (next: Layout) => (view.value = { ...view.value, layout: next }), + onError: (error: GridLayoutRuntimeError) => errors.push(error), + rowHeight: 30, + isDraggable: false, + isResizable: false, + }, + { default: () => view.value.layout.map(item => h(Cell, { key: item.i, id: item.i })) }, + ) + + const wrapper = mount(host, { attachTo: document.body }) + return { wrapper, view, errors } +} + +describe('300 个单元格时的注册表校验开销', () => { + it('挂载时每个单元格的校验次数有上限,而不是每个单元格触发一轮', async () => { + resetCounters() + const { wrapper, errors } = await mountGrid() + await flush() + + // 合并前:每次注册和每次重新渲染各一轮,300 个单元格共 601 轮。现在:所有注册共用的一轮、 + // GridLayout 自身挂载时的一轮,以及已注册状态触发的重新渲染共用的一轮。轮数不随单元格数增长。 + expect(counters.passes).toBeLessThanOrEqual(3) + expect(counters.membership).toBeLessThanOrEqual(3 * size) + expect(counters.linearSearches).toBeLessThan(size) + expect(wrapper.findAll('.vgl-item:not(.vgl-item--placeholder)').length).toBe(size) + expect(errors).toEqual([]) + wrapper.unmount() + }) + + it('宽度与 colNum 切换时,所有更新的单元格共用一轮延迟校验', async () => { + const { wrapper, view, errors } = await mountGrid() + await flush() + + for (const next of [ + { layout: mobile(), colNum: 1, width: 390 }, + { layout: desktop(), colNum: 12, width: 1200 }, + ]) { + resetCounters() + view.value = next + await flush() + + // 外部布局提交后的同步一轮,然后 300 个重新渲染的单元格共用一轮延迟校验(此前:1 + 300)。 + expect(counters.passes).toBe(2) + expect(counters.membership).toBe(2 * size) + expect(counters.linearSearches).toBeLessThan(size) + } + expect(errors).toEqual([]) + wrapper.unmount() + }) +}) + +function fakeItem(i: LayoutItem['i'], wrapper: HTMLElement): GridItemRegistration { + return { + i, + wrapper, + state: { registered: false, style: {} }, + resetInteractionState: vi.fn(), + finishDragInteraction: vi.fn(), + finishResizeInteraction: vi.fn(), + refreshPositionStyle: vi.fn(), + disableInteractionBinding: vi.fn(), + } +} + +function createFixture(ids: LayoutItem['i'][]) { + const root = document.createElement('div') + root.className = 'vgl-layout' + document.body.append(root) + const scheduled: (() => void)[] = [] + const errors: GridLayoutRuntimeError[] = [] + const layoutIds = new Set(ids) + let activeId: LayoutItem['i'] | null = null + const calls: string[] = [] + const registry = createGridItemRegistry({ + itemInstances: new Map(), + registeredItems: new Set(), + registrationEpisodes: new WeakMap(), + isUnavailable: () => false, + getRoot: () => root, + hasLayoutItem: id => layoutIds.has(id), + getActiveInteractionId: () => activeId, + prepareActiveForTerminal: () => calls.push('prepare'), + finishActiveForExternalUpdate: () => calls.push('finish'), + scheduleValidation: callback => scheduled.push(callback), + nextEvaluationId: () => 0, + emitError: error => { + calls.push('error') + errors.push(error) + }, + }) + const item = (id: LayoutItem['i']) => { + const node = document.createElement('div') + root.append(node) + return fakeItem(id, node) + } + const runScheduled = () => scheduled.splice(0).forEach(callback => callback()) + return { + root, + registry, + scheduled, + errors, + calls, + item, + runScheduled, + setActive: (id: LayoutItem['i'] | null) => (activeId = id), + } +} + +describe('元素注册表调度', () => { + it('将注册与同一 id 的更新合并为一轮延迟校验', () => { + const fixture = createFixture(['a', 'b']) + const a = fixture.item('a') + const b = fixture.item('b') + + fixture.registry.increase(a) + fixture.registry.increase(b) + expect(fixture.scheduled).toHaveLength(1) + fixture.runScheduled() + expect(a.state.registered).toBe(true) + expect(b.state.registered).toBe(true) + + fixture.registry.update(a, 'a') + fixture.registry.update(b, 'b') + fixture.registry.update(a, 'a') + expect(fixture.scheduled).toHaveLength(1) + }) + + it('无效注册由延迟校验上报,而不是在更新调用内上报', () => { + const fixture = createFixture(['a']) + const a = fixture.item('a') + fixture.registry.increase(a) + fixture.runScheduled() + + document.body.append(a.wrapper!) + fixture.registry.update(a, 'a') + // 更新本身(GridItem 的 onUpdated)不再同步校验。 + expect(fixture.errors).toEqual([]) + expect(a.state.registered).toBe(true) + + fixture.runScheduled() + expect(a.state.registered).toBe(false) + expect(fixture.errors).toHaveLength(1) + expect(fixture.errors[0]).toMatchObject({ + code: 'invalid-registration', + cause: { reason: 'outside-root', id: 'a' }, + }) + }) + + it('同步校验会消费尚未执行的延迟校验', () => { + const fixture = createFixture(['a']) + const a = fixture.item('a') + fixture.registry.increase(a) + fixture.runScheduled() + + document.body.append(a.wrapper!) + fixture.registry.update(a, 'a') + fixture.registry.validate() + expect(fixture.errors).toHaveLength(1) + + fixture.runScheduled() + expect(fixture.errors).toHaveLength(1) + + // 之后的请求会排入新的一轮。 + fixture.registry.update(a, 'a') + expect(fixture.scheduled).toHaveLength(1) + }) + + it('id 变化仍同步取消旧 id 的进行中交互', () => { + const fixture = createFixture(['a', 'b']) + const a = fixture.item('a') + fixture.registry.increase(a) + fixture.runScheduled() + fixture.setActive('a') + + a.i = 'b' + fixture.registry.update(a, 'a') + expect(fixture.calls).toEqual(['prepare', 'finish']) + expect(fixture.scheduled).toHaveLength(1) + }) + + it('被延迟校验判为无效的活动元素保持 prepare、error、finish 顺序', () => { + const fixture = createFixture(['a']) + const a = fixture.item('a') + fixture.registry.increase(a) + fixture.runScheduled() + fixture.setActive('a') + + document.body.append(a.wrapper!) + fixture.registry.update(a, 'a') + expect(fixture.calls).toEqual([]) + fixture.runScheduled() + expect(fixture.calls).toEqual(['prepare', 'error', 'finish']) + expect(a.refreshPositionStyle).toHaveBeenCalledTimes(1) + }) + + it('持续存在的错误只上报一次,恢复后再出现才再次上报', () => { + const fixture = createFixture(['a']) + const a = fixture.item('a') + fixture.registry.increase(a) + fixture.runScheduled() + const node = a.wrapper! + + document.body.append(node) + for (let pass = 0; pass < 3; pass++) { + fixture.registry.update(a, 'a') + fixture.runScheduled() + } + expect(fixture.errors).toHaveLength(1) + + fixture.root.append(node) + fixture.registry.update(a, 'a') + fixture.runScheduled() + expect(a.state.registered).toBe(true) + + document.body.append(node) + fixture.registry.update(a, 'a') + fixture.runScheduled() + expect(fixture.errors).toHaveLength(2) + }) + + it('首个持有者移除后,另一个同 id 元素接管', () => { + const fixture = createFixture(['a']) + const first = fixture.item('a') + const second = fixture.item('a') + fixture.registry.increase(first) + fixture.registry.increase(second) + fixture.runScheduled() + expect(first.state.registered).toBe(true) + expect(second.state.registered).toBe(false) + expect(fixture.errors[0]).toMatchObject({ cause: { reason: 'duplicate', id: 'a' } }) + + fixture.registry.decrease(first) + fixture.runScheduled() + expect(second.state.registered).toBe(true) + expect(fixture.registry.get('a')).toBe(second) + }) +}) From b41e478608928e5033b942c17a3c150de0ac401a Mon Sep 17 00:00:00 2001 From: zahid emre zorbaz Date: Tue, 6 Oct 2026 23:03:36 +0300 Subject: [PATCH 6/9] fix(grid-layout): route deferred validation errors to the app errorHandler Vue runs post-flush callbacks without try/finally, so a throw from the queuePostFlushCb validation left activePostFlushCbs set and stopped every later updated/mounted hook and flush: 'post' watcher on the page. The callback now catches and reports through handleError (SCHEDULER) on the GridLayout instance; a throwing errorHandler is rethrown from a microtask. Tests: the scheduler keeps running after a throwing pass; an invalid registration is emitted in the same flush, before a later nextTick; an id removed by an external layout update is missing-id through the index. --- .../report-registry-validation-errors.md | 5 + src/components/grid-layout.vue | 27 +++- tests/registry-validation.spec.tsx | 119 +++++++++++++++++- 3 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 .changeset/report-registry-validation-errors.md diff --git a/.changeset/report-registry-validation-errors.md b/.changeset/report-registry-validation-errors.md new file mode 100644 index 0000000..7e59c88 --- /dev/null +++ b/.changeset/report-registry-validation-errors.md @@ -0,0 +1,5 @@ +--- +'grid-layout-plus': patch +--- + +Report deferred registry validation errors through the application error handler without interrupting Vue's scheduler. diff --git a/src/components/grid-layout.vue b/src/components/grid-layout.vue index deea02c..211da25 100644 --- a/src/components/grid-layout.vue +++ b/src/components/grid-layout.vue @@ -7,7 +7,10 @@ * 连接各模块、派发公共事件,并向直属 GridItem 提供已提交的运行时配置。 */ import { + ErrorCodes, computed, + getCurrentInstance, + handleError, nextTick, onBeforeMount, onBeforeUnmount, @@ -734,8 +737,16 @@ const itemRegistry = createGridItemRegistry({ }, // 在当前 flush 的 post 阶段末尾执行(排在本轮所有 updated/mounted 钩子之后), // 校验结果触发的 watcher 仍在同一次 flush 内完成,与原先 onUpdated 内同步校验的时序一致。 + // Vue 执行 post 回调时没有 try/finally:回调抛出会让调度器停在 post 阶段,页面上所有应用的 + // updated/mounted 钩子和 flush: 'post' watcher 都不再执行。因此异常在此捕获并交给应用的 errorHandler。 scheduleValidation: callback => { - queuePostFlushCb(() => runAsyncBoundary(callback)) + queuePostFlushCb(() => { + try { + runAsyncBoundary(callback) + } catch (error) { + reportPostFlushError(error) + } + }) }, nextEvaluationId, emitError: error => emit('error', error), @@ -1072,6 +1083,20 @@ function sealCounter(error: GridLayoutValidationError, emitRuntime: boolean): vo } } +const layoutInstance = getCurrentInstance() + +/** 把 post 回调中的异常交给应用 errorHandler(无 errorHandler 时仅打印),不向调度器抛出。 */ +function reportPostFlushError(error: unknown): void { + try { + handleError(error, layoutInstance, ErrorCodes.SCHEDULER, false) + } catch (handlerError) { + // errorHandler 自身抛出时,在调度器之外重新抛出,保留未捕获异常的可见性。 + queueMicrotask(() => { + throw handlerError + }) + } +} + function runAsyncBoundary(callback: () => T): T | undefined { if (disposing || sealedError) return undefined try { diff --git a/tests/registry-validation.spec.tsx b/tests/registry-validation.spec.tsx index cc2b359..a3bbe2e 100644 --- a/tests/registry-validation.spec.tsx +++ b/tests/registry-validation.spec.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' -import { defineComponent, h, nextTick, ref } from 'vue' +import { defineComponent, h, nextTick, onUpdated, ref, watch } from 'vue' import { GridItem, GridLayout } from '../src' import { createGridItemRegistry } from '../src/components/grid-layout/item-registry' @@ -67,10 +67,17 @@ const offsetParentDescriptor = Object.getOwnPropertyDescriptor( 'offsetParent', ) +// 按元素覆盖:`detached` 表示没有包含块,`throws` 让读取抛错。 +const offsetParentOverrides = new Map() + beforeEach(() => { + offsetParentOverrides.clear() Object.defineProperty(HTMLElement.prototype, 'offsetParent', { configurable: true, get(this: HTMLElement) { + const override = offsetParentOverrides.get(this) + if (override === 'throws') throw new Error('offsetParent read failed') + if (override === 'detached') return null return this.parentElement?.closest('.vgl-layout') ?? null }, }) @@ -177,6 +184,116 @@ describe('300 个单元格时的注册表校验开销', () => { }) }) +/** + * 单元格按自己的列表渲染,因此布局可以移除某个 id 而其单元格仍保持挂载。 + * 应用的 `errorHandler` 收集 Vue 转交给它的错误。 + */ +async function mountCells(ids: string[]) { + const layout = ref(ids.map((i, k) => ({ i, x: k * 2, y: 0, w: 2, h: 2 }))) + const cells = ref(ids) + const errors: GridLayoutRuntimeError[] = [] + const appErrors: unknown[] = [] + + const host = () => + h( + GridLayout, + { + layout: layout.value, + 'onUpdate:layout': (next: Layout) => (layout.value = next), + onError: (error: GridLayoutRuntimeError) => errors.push(error), + colNum: 12, + width: 1200, + rowHeight: 30, + isDraggable: false, + isResizable: false, + }, + { default: () => cells.value.map(id => h(Cell, { key: id, id })) }, + ) + + const wrapper = mount(host, { + attachTo: document.body, + global: { config: { errorHandler: error => void appErrors.push(error) } }, + }) + await flush() + const element = (id: string) => + wrapper.findAll('.vgl-item').find(item => item.text().trim() === id)!.element + const move = (id: string, x: number) => + (layout.value = layout.value.map(item => (item.i === id ? { ...item, x } : item))) + return { wrapper, layout, errors, appErrors, element, move } +} + +describe('GridLayout 内的注册表校验', () => { + it('无效注册在同一次 flush 内上报,早于之后的 nextTick', async () => { + const { wrapper, errors, element, move } = await mountCells(['a', 'b']) + offsetParentOverrides.set(element('a'), 'detached') + + move('a', 6) + // 一次 nextTick 恰好等待重新渲染 `a` 的那次 flush。 + await nextTick() + + expect(errors).toHaveLength(1) + expect(errors[0]).toMatchObject({ + code: 'invalid-registration', + cause: { reason: 'invalid-containing-block', id: 'a' }, + }) + wrapper.unmount() + }) + + it('被外部布局更新移除的 id 通过索引判定为 missing-id', async () => { + const { wrapper, layout, errors } = await mountCells(['a', 'b']) + resetCounters() + + layout.value = layout.value.filter(item => item.i !== 'b') + await flush() + + expect(errors).toHaveLength(1) + expect(errors[0]).toMatchObject({ + code: 'invalid-registration', + cause: { reason: 'missing-id', id: 'b' }, + }) + expect(counters.passes).toBeGreaterThan(0) + expect(counters.linearSearches).toBe(0) + wrapper.unmount() + }) + + it('抛错的延迟校验交给应用 errorHandler,不阻断之后的钩子', async () => { + const { wrapper, appErrors, element, move } = await mountCells(['a', 'b']) + offsetParentOverrides.set(element('a'), 'throws') + + move('a', 6) + await flush() + offsetParentOverrides.clear() + + // 读取失败期间执行的每一轮都上报给应用,没有异常逃逸。 + expect(appErrors.length).toBeGreaterThan(0) + for (const error of appErrors) { + expect(error).toMatchObject({ message: 'offsetParent read failed' }) + } + + // Vue 执行 post 回调时没有 try/finally:其中一个抛错会让调度器停住, + // 之后所有 `updated` 钩子和 `flush: 'post'` watcher 都不再执行。 + const probe = ref(0) + const seen = { updated: 0, postWatcher: 0 } + // 独立的应用:探针与网格只共享 Vue 的调度器。 + const other = mount( + { + setup() { + onUpdated(() => (seen.updated += 1)) + return () => h('i', probe.value) + }, + }, + { attachTo: document.body }, + ) + watch(probe, () => (seen.postWatcher += 1), { flush: 'post' }) + probe.value += 1 + await flush() + + expect(seen).toEqual({ updated: 1, postWatcher: 1 }) + other.unmount() + wrapper.unmount() + }) +}) + function fakeItem(i: LayoutItem['i'], wrapper: HTMLElement): GridItemRegistration { return { i, From 9407418a461744cb919e9e5fe8bdb09f4db7db5b Mon Sep 17 00:00:00 2001 From: zahid emre zorbaz Date: Tue, 6 Oct 2026 23:03:59 +0300 Subject: [PATCH 7/9] perf(grid-item): write the position style to the element instead of re-rendering A box-only change re-rendered every moved GridItem, and each re-render queued a registry pass that read offsetParent and forced a style/layout of the whole grid. GridItem now keeps state.style as the source and syncs a non-reactive renderStyle plus the element's inline style from a synchronous watcher, writing only the changed properties. Other re-renders still patch the current values, so merged fallthrough styles never restore a stale box. The root renders under v-if="renderStyle" so a GridItem outside GridLayout keeps rendering nothing (E2E-26). --- .changeset/write-item-position-style.md | 5 + src/components/grid-item.vue | 45 ++++++- tests/registry-validation.spec.tsx | 7 +- tests/style-equality.spec.tsx | 161 +++++++++++++++++++++++- 4 files changed, 209 insertions(+), 9 deletions(-) create mode 100644 .changeset/write-item-position-style.md diff --git a/.changeset/write-item-position-style.md b/.changeset/write-item-position-style.md new file mode 100644 index 0000000..a53aa9c --- /dev/null +++ b/.changeset/write-item-position-style.md @@ -0,0 +1,5 @@ +--- +'grid-layout-plus': patch +--- + +Write GridItem position styles directly to the element instead of re-rendering the component on every move. diff --git a/src/components/grid-item.vue b/src/components/grid-item.vue index 657d66c..27c86b5 100644 --- a/src/components/grid-item.vue +++ b/src/components/grid-item.vue @@ -8,6 +8,7 @@ import { computed, inject, + markRaw, onBeforeMount, onBeforeUnmount, onMounted, @@ -645,11 +646,46 @@ watch( ) // 内容相同时保留原样式对象:父布局每次提交都会让所有 GridItem 重新求值样式, -// 若为盒子未变的元素写入新对象,响应式会触发一次无意义的重新渲染。 +// 若为盒子未变的元素写入新对象,下方的同步 watcher 会做一次无意义的 DOM 写入。 function assignStyle(next: Record) { if (!sameStyle(state.style, next)) state.style = next } +// 定位样式不触发 GridItem 重新渲染:模板绑定的 renderStyle 是非响应式对象, +// state.style 变化时由同步 watcher 同时改写它与根元素的内联样式。 +// 只有盒子变化时因此没有 render,也没有 onUpdated → 注册表校验(校验读取 offsetParent, +// 会强制浏览器计算样式与布局)。定位样式的键与值由 validatePositionStyleResult 限定, +// 改变它们不会改变本元素的 containing block,跳过这一轮校验不削弱归属保证。 +// renderStyle 始终等于已写入的样式:SSR、首次挂载和其他原因引起的重新渲染都输出当前值, +// Vue 的 style patch(含与父级透传 style 的合并)不会写回过期的定位。 +// 模板根节点以 v-if="renderStyle" 渲染:不在 GridLayout 下时 setup 在此之前抛错,renderStyle 不存在, +// 组件不输出任何 DOM(E2E-26 契约;此前由模板读取 state.style 时的渲染异常间接保证)。 +const renderStyle: Record = markRaw({}) + +function toCssPropertyName(key: string): string { + return key.startsWith('--') ? key : key.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`) +} + +function writePositionStyle(next: Readonly>) { + const element = wrapper.value + for (const key of Object.keys(renderStyle)) { + if (Object.prototype.hasOwnProperty.call(next, key)) continue + delete renderStyle[key] + element?.style.removeProperty(toCssPropertyName(key)) + } + for (const [key, value] of Object.entries(next)) { + if (renderStyle[key] === value) continue + renderStyle[key] = value + element?.style.setProperty(toCssPropertyName(key), value) + } +} + +watch( + () => state.style, + next => writePositionStyle(next), + { flush: 'sync' }, +) + function createStyle() { if (!state.registered) { assignStyle({}) @@ -1626,7 +1662,12 @@ function syncAutoHeightTarget(): void {