diff --git a/.changeset/batch-registry-ownership-reads.md b/.changeset/batch-registry-ownership-reads.md
index 2f40acb..262b6b3 100644
--- a/.changeset/batch-registry-ownership-reads.md
+++ b/.changeset/batch-registry-ownership-reads.md
@@ -2,4 +2,4 @@
'grid-layout-plus': patch
---
-Read all item ownership facts before updating registry state and styles, reducing repeated layout calculations for rejected items.
+Reduce repeated browser layout calculations when validating item registration.
diff --git a/.changeset/coalesce-registry-validation.md b/.changeset/coalesce-registry-validation.md
index cf4406b..78e364c 100644
--- a/.changeset/coalesce-registry-validation.md
+++ b/.changeset/coalesce-registry-validation.md
@@ -2,4 +2,4 @@
'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.
+Reduce mounting and layout update overhead for grids with many items.
diff --git a/.changeset/copy-sealed-layouts.md b/.changeset/copy-sealed-layouts.md
index 0e49e63..ee18306 100644
--- a/.changeset/copy-sealed-layouts.md
+++ b/.changeset/copy-sealed-layouts.md
@@ -2,4 +2,5 @@
'grid-layout-plus': patch
---
-Copy the engine's internal frozen layouts without re-validating every item, reducing per-drag-step cloning cost.
+Reduce per-drag-step cloning cost by avoiding repeated validation of layouts already checked by
+the engine.
diff --git a/.changeset/keep-equal-item-styles.md b/.changeset/keep-equal-item-styles.md
index 844a13c..dbb70af 100644
--- a/.changeset/keep-equal-item-styles.md
+++ b/.changeset/keep-equal-item-styles.md
@@ -2,4 +2,4 @@
'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.
+Avoid `GridItem` re-renders caused by unchanged position styles when another item moves.
diff --git a/.changeset/report-registry-validation-errors.md b/.changeset/report-registry-validation-errors.md
deleted file mode 100644
index 7e59c88..0000000
--- a/.changeset/report-registry-validation-errors.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'grid-layout-plus': patch
----
-
-Report deferred registry validation errors through the application error handler without interrupting Vue's scheduler.
diff --git a/.changeset/same-tick-col-num.md b/.changeset/same-tick-col-num.md
index 5fe8e1d..a5b8be6 100644
--- a/.changeset/same-tick-col-num.md
+++ b/.changeset/same-tick-col-num.md
@@ -2,4 +2,6 @@
'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.
+Validate a layout that changes in the same tick as `colNum` against the incoming column count
+when `responsive` is disabled, avoiding transient `invalid-layout` and `invalid-registration`
+errors.
diff --git a/.changeset/sweep-collision-checks.md b/.changeset/sweep-collision-checks.md
index f4988f1..98f8d64 100644
--- a/.changeset/sweep-collision-checks.md
+++ b/.changeset/sweep-collision-checks.md
@@ -2,4 +2,4 @@
'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.
+Speed up overlap detection, especially for vertically stacked collision-free layouts.
diff --git a/.changeset/write-item-position-style.md b/.changeset/write-item-position-style.md
index a53aa9c..7322f20 100644
--- a/.changeset/write-item-position-style.md
+++ b/.changeset/write-item-position-style.md
@@ -2,4 +2,4 @@
'grid-layout-plus': patch
---
-Write GridItem position styles directly to the element instead of re-rendering the component on every move.
+Avoid `GridItem` re-renders caused only by position changes while preserving custom style overrides.
diff --git a/dev-server/e2e/placement-fixture.vue b/dev-server/e2e/placement-fixture.vue
new file mode 100644
index 0000000..e9c7181
--- /dev/null
+++ b/dev-server/e2e/placement-fixture.vue
@@ -0,0 +1,69 @@
+
+
+
+
+
+ Add
+
+
+ Move
+
+
{{ transitions }}
+
+
+
+
+
+
+
diff --git a/dev-server/router.ts b/dev-server/router.ts
index b9e36c9..a8d6145 100644
--- a/dev-server/router.ts
+++ b/dev-server/router.ts
@@ -1,6 +1,7 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import ContractFixture from './e2e/contract-fixture.vue'
+import PlacementFixture from './e2e/placement-fixture.vue'
document.title = 'dev | Grid Layout Plus'
@@ -9,6 +10,12 @@ const demos = import.meta.glob('../docs/demos/*.vue')
export const router = createRouter({
history: createWebHashHistory('/'),
routes: [
+ {
+ path: '/__e2e/placement',
+ name: 'placement-fixture',
+ component: PlacementFixture,
+ meta: { internal: true },
+ },
{
path: '/__e2e/contracts/:phase',
name: 'contract-fixture',
diff --git a/docs/guide/methods.md b/docs/guide/methods.md
index b7b2a16..a6c5ae1 100644
--- a/docs/guide/methods.md
+++ b/docs/guide/methods.md
@@ -99,3 +99,9 @@ The component emits the current Layout and the complete breakpoint map with the
## Component methods or composables?
Use these methods when `GridLayout` renders the UI. Use [`useGridLayout`](./composables#usegridlayout) for headless state: its one-off methods commit synchronously and return `LayoutOperationResult`, so they use `accepted` instead of `pending`.
+
+## GridItem component ref
+
+`GridItem` exposes `wrapper`, its root element, and reactive `state`. Changes to `state.style`,
+including individual properties, are reflected in the DOM with the same fallthrough-style precedence.
+Layout updates may replace `state.style`; use the `style` attribute for persistent presentation overrides.
diff --git a/docs/guide/properties.md b/docs/guide/properties.md
index b673e1e..7590400 100644
--- a/docs/guide/properties.md
+++ b/docs/guide/properties.md
@@ -631,6 +631,10 @@ Programmatic layout and layer operations are documented in [Methods](./methods).
`GridItem` must be a descendant of `GridLayout`. Its `i` prop associates it with the matching `LayoutItem`; the parent Layout owns geometry, constraints, static state, per-item drag and resize flags, resize handles, and layer order.
+HTML `class` and `style` attributes are forwarded to the root element. Custom styles keep Vue's
+fallthrough precedence during layout updates and do not change the stored layout geometry.
+New items appear without an initial positioning transition; later position changes animate normally.
+
### i
- type: `number | string`
diff --git a/docs/zh/guide/methods.md b/docs/zh/guide/methods.md
index 41fcc4b..f6812b3 100644
--- a/docs/zh/guide/methods.md
+++ b/docs/zh/guide/methods.md
@@ -99,3 +99,8 @@ function moveSummary() {
## 组件方法还是组合式函数?
由 `GridLayout` 渲染界面时使用这些组件方法。无头状态使用 [`useGridLayout`](./composables#usegridlayout):它的单次操作会同步提交并返回 `LayoutOperationResult`,所以状态是 `accepted`,而不是 `pending`。
+
+## GridItem 组件引用
+
+`GridItem` 暴露根元素 `wrapper` 和响应式 `state`。修改 `state.style`(包括单个属性)会同步到 DOM,
+并保留相同的透传样式优先级。布局更新可能替换 `state.style`;需要持续生效的展示覆盖请使用 `style` 属性。
diff --git a/docs/zh/guide/properties.md b/docs/zh/guide/properties.md
index eefa9be..4dd23d3 100644
--- a/docs/zh/guide/properties.md
+++ b/docs/zh/guide/properties.md
@@ -626,6 +626,9 @@ interface DropConfig {
`GridItem` 必须位于 `GridLayout` 内。它通过 `i` 属性关联对应的 `LayoutItem`;几何信息、约束、静态状态、单项拖拽和缩放开关、缩放手柄以及层级顺序都由父级 Layout 维护。
+HTML `class` 和 `style` 属性会透传到根元素。自定义样式在布局更新期间保留 Vue 的透传优先级,
+并保持已存储的布局几何信息不变。新元素首次定位时直接出现,之后的位置变化保留正常过渡动画。
+
### i
- 类型:`number | string`
diff --git a/scripts/benchmark.ts b/scripts/benchmark.ts
index 31b2b32..e8beb22 100644
--- a/scripts/benchmark.ts
+++ b/scripts/benchmark.ts
@@ -1,5 +1,6 @@
import { performance } from 'node:perf_hooks'
+import { hasAnyCollision } from '../src/core/collision-sweep'
import {
fastHorizontalCompactor,
fastVerticalCompactor,
@@ -96,6 +97,16 @@ function highCandidateVertical(): Layout {
}))
}
+function stackedVertical(): Layout {
+ let y = 0
+ return Array.from({ length: itemCount }, (_, index) => {
+ const h = 1 + (index % 7)
+ const item = { i: `stacked-${index}`, x: 0, y, w: 12, h }
+ y += h
+ return item
+ })
+}
+
const cases: BenchmarkCase[] = [
{
name: 'vertical / sparse',
@@ -155,15 +166,15 @@ function resultChecksum(layout: Layout): number {
return checksum
}
-function measure(run: () => Layout): TimingResult {
+function measure(run: () => T, checksumFor: (result: T) => number): TimingResult {
for (let index = 0; index < warmups; index++) run()
let checksum = 0
const durations = Array.from({ length: samples }, () => {
const start = performance.now()
- const layout = run()
+ const result = run()
const duration = performance.now() - start
- checksum ^= resultChecksum(layout)
+ checksum ^= checksumFor(result)
return duration
}).sort((first, second) => first - second)
@@ -181,8 +192,8 @@ function rounded(value: number): number {
function runCompactorBenchmarks(): void {
const rows = cases.flatMap(entry => {
- const standard = measure(() => entry.standard.compact(entry.layout, entry.cols))
- const indexed = measure(() => entry.indexed.compact(entry.layout, entry.cols))
+ const standard = measure(() => entry.standard.compact(entry.layout, entry.cols), resultChecksum)
+ const indexed = measure(() => entry.indexed.compact(entry.layout, entry.cols), resultChecksum)
return [
{
dataset: entry.name,
@@ -209,6 +220,24 @@ function runCompactorBenchmarks(): void {
console.table(rows)
}
+function runCollisionBenchmark(): void {
+ const layout = stackedVertical()
+ const timing = measure(() => hasAnyCollision(layout), Number)
+
+ console.log(
+ `\nCollision sweep benchmark (${itemCount} items, ${warmups} warmups, ${samples} samples)`,
+ )
+ console.table([
+ {
+ dataset: 'vertical / stacked',
+ medianMs: rounded(timing.medianMs),
+ p95Ms: rounded(timing.p95Ms),
+ minMs: rounded(timing.minMs),
+ checksum: timing.checksum,
+ },
+ ])
+}
+
function heapUsed(): number {
return process.memoryUsage().heapUsed
}
@@ -293,4 +322,5 @@ function runInteractionBenchmark(): void {
console.log(`Grid Layout Plus benchmark profile: ${quick ? 'quick' : 'full'}`)
console.log('Results are evidence only and do not enforce machine-dependent timing thresholds.')
runCompactorBenchmarks()
+runCollisionBenchmark()
runInteractionBenchmark()
diff --git a/src/components/grid-item.vue b/src/components/grid-item.vue
index 2110681..e18b839 100644
--- a/src/components/grid-item.vue
+++ b/src/components/grid-item.vue
@@ -9,6 +9,7 @@ import {
computed,
inject,
markRaw,
+ normalizeStyle,
onBeforeMount,
onBeforeUnmount,
onMounted,
@@ -18,6 +19,7 @@ import {
shallowRef,
toRaw,
toRef,
+ useAttrs,
watch,
watchEffect,
} from 'vue'
@@ -68,6 +70,7 @@ const props = withDefaults(defineProps(), {
decorative: false,
})
+const attrs = useAttrs()
const emit = defineEmits()
defineSlots()
@@ -125,6 +128,9 @@ const state = reactive({
// 首次放置是否仍在等待注册表结论,见 createStyle。
let placementPending = !props.decorative && !props.internal
+// 动态新增项可能先挂载、后接纳布局;首个有效位置绘制后再开启该项的过渡。
+const firstPositionPending = ref(!props.decorative)
+let firstPositionFrame: number | undefined
let dragEventSet = false
let resizeEventSet = false
@@ -227,7 +233,7 @@ const instance = reactive({
finishDragInteraction,
finishResizeInteraction,
refreshPositionStyle: createStyle,
- clearPositionStyle: () => clearPositionStyle(),
+ clearPositionStyle,
disableInteractionBinding,
})
@@ -313,6 +319,7 @@ onBeforeMount(() => {
})
onMounted(() => {
+ if (Object.keys(state.style).length) finishFirstPosition()
if (!props.decorative) layout.increaseItem(instance)
if (layout.responsive && layout.lastBreakpoint) {
state.cols = getColsFromBreakpoint(layout.lastBreakpoint, layout.cols)
@@ -365,6 +372,7 @@ onUpdated(() => {
})
onBeforeUnmount(() => {
+ if (firstPositionFrame !== undefined) cancelAnimationFrame(firstPositionFrame)
emitter.off('updateWidth', updateWidthHandler)
emitter.off('compact', compactHandler)
emitter.off('setDraggable', setDraggableHandler)
@@ -386,7 +394,12 @@ onBeforeUnmount(() => {
}
})
-defineExpose({ state, wrapper })
+defineExpose({
+ /** Reactive item state. Changes to `style` are reflected in the DOM; layout updates may replace it. */
+ state,
+ /** The component root element, available after mounting. */
+ wrapper,
+})
const isAndroid =
typeof navigator !== 'undefined' ? navigator.userAgent.toLowerCase().includes('android') : false
@@ -421,6 +434,7 @@ const className = computed(() => {
[nh.b()]: true,
[nh.bm('resizable')]: resizableAndNotStatic.value,
[nh.bm('static')]: effectiveStatic.value,
+ [nh.bm('placing')]: firstPositionPending.value,
[nh.bm('auto-height')]: effectiveAutoHeight.value,
[nh.bm('resizing')]: state.isResizing,
[nh.bm('dragging')]: state.isDragging,
@@ -655,7 +669,7 @@ function assignStyle(next: Record) {
if (!sameStyle(state.style, next)) state.style = next
}
-// 定位样式不触发 GridItem 重新渲染:模板绑定的 renderStyle 是非响应式对象,
+// 定位样式不触发 GridItem 重新渲染:renderStyle 使用浅层引用,普通定位只修改其非响应式内容,
// state.style 变化时由同步 watcher 同时改写它与根元素的内联样式。
// 只有盒子变化时因此没有 render,也没有 onUpdated → 注册表校验(校验读取 offsetParent,
// 会强制浏览器计算样式与布局)。定位样式的键与值由 validatePositionStyleResult 限定,
@@ -664,30 +678,80 @@ function assignStyle(next: Record) {
// Vue 的 style patch(含与父级透传 style 的合并)不会写回过期的定位。
// 模板根节点以 v-if="renderStyle" 渲染:不在 GridLayout 下时 setup 在此之前抛错,renderStyle 不存在,
// 组件不输出任何 DOM(E2E-26 契约;此前由模板读取 state.style 时的渲染异常间接保证)。
-const renderStyle: Record = markRaw({})
+const renderStyle = shallowRef>({})
function toCssPropertyName(key: string): string {
return key.startsWith('--') ? key : key.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)
}
+function setStyleProperty(style: CSSStyleDeclaration, key: string, value: unknown) {
+ if (Array.isArray(value)) {
+ value.forEach(entry => setStyleProperty(style, key, entry))
+ return
+ }
+ const text = value == null ? '' : String(value)
+ const important = !key.startsWith('--') && /\s*!important$/.test(text)
+ style.setProperty(
+ toCssPropertyName(key),
+ important ? text.replace(/\s*!important$/, '') : text,
+ important ? 'important' : '',
+ )
+}
+
function writePositionStyle(next: Readonly>) {
const element = wrapper.value
- for (const key of Object.keys(renderStyle)) {
+ const displayChanged = renderStyle.value.display !== next.display
+ // 需要 Vue 接管时保留旧渲染快照,让 style patch 能识别被删除的 display。
+ const current = displayChanged ? { ...renderStyle.value } : renderStyle.value
+ // 与模板的透传样式保持同一优先级;数组包装同时归一化对象、字符串和样式数组。
+ const normalized = normalizeStyle([attrs.style])
+ const overrides = typeof normalized === 'object' && normalized !== null ? normalized : {}
+ for (const key of Object.keys(current)) {
if (Object.prototype.hasOwnProperty.call(next, key)) continue
- delete renderStyle[key]
- element?.style.removeProperty(toCssPropertyName(key))
+ delete current[key]
+ if (key !== 'display') 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)
+ if (current[key] === value) continue
+ current[key] = value
+ if (element && key !== 'display') setStyleProperty(element.style, key, value)
+ }
+ // display 的显隐状态交给 Vue/v-show;只有该值变化时才需要一次组件渲染。
+ if (displayChanged) renderStyle.value = current
+ if (element && attrs.style) {
+ // 简写(如 inset)也可能覆盖定位属性,因此在布局写入后统一恢复透传样式。
+ // display 由 Vue/v-show 管理;定位更新保持已有显隐状态。
+ const display = element.style.display
+ const priority = element.style.getPropertyPriority('display')
+ for (const [key, value] of Object.entries(overrides)) {
+ if (key !== 'display') setStyleProperty(element.style, key, value)
+ }
+ element.style.setProperty('display', display, priority)
}
+ if (Object.keys(next).length) {
+ finishFirstPosition()
+ } else if (firstPositionFrame !== undefined) {
+ cancelAnimationFrame(firstPositionFrame)
+ firstPositionFrame = undefined
+ }
+}
+
+function finishFirstPosition(): void {
+ // setup 读到的已提交位置可能仍会在本轮挂载后更新,统一等首帧绘制后再开启过渡。
+ if (!firstPositionPending.value || !wrapper.value) return
+ if (firstPositionFrame !== undefined) return
+ firstPositionFrame = requestAnimationFrame(() => {
+ firstPositionFrame = requestAnimationFrame(() => {
+ firstPositionFrame = undefined
+ firstPositionPending.value = false
+ })
+ })
}
watch(
() => state.style,
next => writePositionStyle(next),
- { flush: 'sync' },
+ { flush: 'sync', deep: true },
)
// 首次放置在注册表给出结论之前处于待定状态。待定期间直接使用父布局已提交的位置样式(乐观首帧),
diff --git a/src/components/grid-layout.vue b/src/components/grid-layout.vue
index 211da25..48834d8 100644
--- a/src/components/grid-layout.vue
+++ b/src/components/grid-layout.vue
@@ -735,8 +735,8 @@ const itemRegistry = createGridItemRegistry({
finishActiveForExternalUpdate: () => {
finishInteraction('cancelled', 'external-update', { nativeEvent: null })
},
- // 在当前 flush 的 post 阶段末尾执行(排在本轮所有 updated/mounted 钩子之后),
- // 校验结果触发的 watcher 仍在同一次 flush 内完成,与原先 onUpdated 内同步校验的时序一致。
+ // 在当前 flush 的 post 阶段合并执行;校验结果触发的 watcher 仍在同一次 flush 内完成。
+ // 与各生命周期钩子的先后顺序取决于入队时机,不保证排在所有 mounted/updated 钩子之后。
// Vue 执行 post 回调时没有 try/finally:回调抛出会让调度器停在 post 阶段,页面上所有应用的
// updated/mounted 钩子和 flush: 'post' watcher 都不再执行。因此异常在此捕获并交给应用的 errorHandler。
scheduleValidation: callback => {
diff --git a/src/components/types.ts b/src/components/types.ts
index 44f91b0..458f0f6 100644
--- a/src/components/types.ts
+++ b/src/components/types.ts
@@ -458,7 +458,12 @@ export interface GridLayoutProps {
transferConfig?: TransferConfig
}
-/** Props accepted by the `GridItem` component. */
+/**
+ * Props accepted by the `GridItem` component.
+ *
+ * HTML style attributes retain Vue's fallthrough precedence without changing layout geometry.
+ * Initial placement does not animate; later position changes use the component's CSS transitions.
+ */
export interface GridItemProps {
/** The id of the matching item in the parent layout. */
i: number | string
diff --git a/src/core/normalize.ts b/src/core/normalize.ts
index 9bd4dcc..7363007 100644
--- a/src/core/normalize.ts
+++ b/src/core/normalize.ts
@@ -234,14 +234,9 @@ function validateCompactorResult(
}
}
+ // 此处统一报告压缩器结果无效,无需定位具体冲突项,发现碰撞即可直接拒绝。
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)
- }
- }
- }
+ extensionFailure('extension-invalid-result', result)
}
return output
}
diff --git a/src/helpers/common.ts b/src/helpers/common.ts
index 29a52b2..56e37c8 100644
--- a/src/helpers/common.ts
+++ b/src/helpers/common.ts
@@ -294,7 +294,7 @@ function copySealedItem(item: ReadonlyLayoutItem): LayoutItem {
result,
key,
KNOWN_LAYOUT_ITEM_KEYS.has(key)
- ? key === 'resizeHandles'
+ ? key === 'resizeHandles' && value !== undefined
? Object.freeze(Array.from(value as readonly string[]))
: value
: copySealedMetadata(value),
diff --git a/src/style.scss b/src/style.scss
index 89b58e3..048ad0b 100644
--- a/src/style.scss
+++ b/src/style.scss
@@ -57,6 +57,10 @@
left: auto;
}
+ &--placing {
+ transition: none;
+ }
+
&--resizing {
z-index: max(
calc(var(--vgl-item-z-index, 0) + 1),
diff --git a/tests/collision-sweep.spec.ts b/tests/collision-sweep.spec.ts
index 676ce3b..ee00663 100644
--- a/tests/collision-sweep.spec.ts
+++ b/tests/collision-sweep.spec.ts
@@ -54,17 +54,4 @@ describe('hasAnyCollision', () => {
]),
).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)
- })
})
diff --git a/tests/core-phase-one.spec.ts b/tests/core-phase-one.spec.ts
index 839b68e..54d4387 100644
--- a/tests/core-phase-one.spec.ts
+++ b/tests/core-phase-one.spec.ts
@@ -532,6 +532,21 @@ describe('Phase 1 normalizeLayout and Compactor boundary', () => {
)
expect(invalid.cause).toBe(invalidResult)
+ const overlapping = [item({ i: 'a' }), item({ i: 'b' })]
+ const collision = expectExtension(
+ () =>
+ normalizeLayout([item({ i: 'a' }), item({ i: 'b', y: 1 })], {
+ cols: 2,
+ compactor: {
+ compact() {
+ return overlapping
+ },
+ },
+ }),
+ 'extension-invalid-result',
+ )
+ expect(collision.cause).toBe(overlapping)
+
expectExtension(
() =>
normalizeLayout([item()], {
diff --git a/tests/e2e/placement.spec.ts b/tests/e2e/placement.spec.ts
new file mode 100644
index 0000000..26a11fb
--- /dev/null
+++ b/tests/e2e/placement.spec.ts
@@ -0,0 +1,44 @@
+import { expect, test } from '@playwright/test'
+
+import type { Page } from '@playwright/test'
+
+async function paint(page: Page) {
+ await page.evaluate(
+ () =>
+ new Promise(resolve => {
+ requestAnimationFrame(() =>
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
+ )
+ }),
+ )
+}
+
+for (const preexisting of [false, true]) {
+ test(`同 tick ${preexisting ? '挂载并移动预存项' : '新增元素'}不产生首次定位动画,后续移动保留动画`, async ({
+ page,
+ }) => {
+ const errors: string[] = []
+ page.on('pageerror', error => errors.push(error.message))
+ await page.goto(`/?preexisting=${preexisting}#/__e2e/placement`)
+ await expect(page.locator('[data-placement-item="b"]')).toBeVisible()
+ await paint(page)
+ await page.locator('[data-placement-add]').click()
+ const item = page.locator('[data-placement-item="c"]')
+ await expect(item).toBeVisible()
+ await paint(page)
+ await expect(page.locator('[data-placement-transitions]')).toHaveText('0')
+ // 检查已实际到达 inline 声明的位置,避免只验证动画事件遗漏。
+ const placed = await item.evaluate(element => {
+ const inline = new DOMMatrix((element as HTMLElement).style.transform)
+ const computed = new DOMMatrix(getComputedStyle(element).transform)
+ return inline.m41 > 0 && Math.abs(inline.m41 - computed.m41) < 1
+ })
+ expect(placed).toBe(true)
+
+ await page.locator('[data-placement-move]').click()
+ await expect
+ .poll(async () => Number(await page.locator('[data-placement-transitions]').textContent()))
+ .toBeGreaterThan(0)
+ expect(errors).toEqual([])
+ })
+}
diff --git a/tests/layout-seal.spec.ts b/tests/layout-seal.spec.ts
index 0437003..7d51506 100644
--- a/tests/layout-seal.spec.ts
+++ b/tests/layout-seal.spec.ts
@@ -2,23 +2,13 @@
* @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 { describe, expect, it } from 'vitest'
-import { createLayoutEngine, defaultInternalConfig } from '../src/core/layout-engine'
+import {
+ createLayoutEngine,
+ createNormalizedLayoutEngine,
+ defaultInternalConfig,
+} from '../src/core/layout-engine'
import { verticalCompactor } from '../src/core/compactors'
import { cloneLayout, isSealedLayout, sealLayout } from '../src/helpers/common'
@@ -55,31 +45,21 @@ function isAnyFrozen(value: unknown): boolean {
return Object.values(value).some(isAnyFrozen)
}
-beforeEach(() => {
- cloneCounter.items = 0
-})
+describe('封存布局的复制语义', () => {
+ it('初始化和拖拽时保留显式 undefined 的 resizeHandles', () => {
+ const initial: Layout = [{ i: 'item', x: 0, y: 0, w: 1, h: 1, resizeHandles: undefined }]
+ const { engine, layout } = createNormalizedLayoutEngine(initial, config())
+ expect(layout).toStrictEqual(initial)
-describe('引擎封存的布局复制时不再重复校验', () => {
- it('一次拖拽步进只校验它新产生的布局', () => {
- const engine = createLayoutEngine(grid(), config())
- const started = engine.beginInteraction({ type: 'drag', id: 'n0' })
+ const started = engine.beginInteraction({ type: 'drag', id: 'item' })
if (started.status !== 'accepted') throw new Error('drag rejected')
+ expect(started.session.baseLayout).toStrictEqual(initial)
- 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)
+ const evaluation = engine.evaluateInteraction(started.session, { type: 'drag', x: 1, y: 0 })
+ const confirmed = engine.confirm(evaluation)
+ expect(confirmed.status).toBe('accepted')
+ expect(confirmed.layout).toStrictEqual([{ ...initial[0], x: 1 }])
+ engine.closeInteraction(started.session)
})
it('快速复制与校验克隆结果相同,且独立、可修改', () => {
diff --git a/tests/registry-validation.spec.tsx b/tests/registry-validation.spec.tsx
index 82c83ed..cb11b1d 100644
--- a/tests/registry-validation.spec.tsx
+++ b/tests/registry-validation.spec.tsx
@@ -10,44 +10,6 @@ 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 = {}
@@ -90,12 +52,6 @@ afterEach(() => {
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()
@@ -146,39 +102,23 @@ async function mountGrid() {
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 () => {
+describe('大量元素的注册与布局更新', () => {
+ it('挂载及列数切换后所有元素均有有效定位', async () => {
const { wrapper, view, errors } = await mountGrid()
await flush()
-
for (const next of [
+ view.value,
{ layout: mobile(), colNum: 1, width: 390 },
{ layout: desktop(), colNum: 12, width: 1200 },
]) {
- resetCounters()
view.value = next
await flush()
-
- // 只有外部布局提交后的同步一轮。单元格通过直接写入样式得到新盒子,不会重新渲染,
- // 也不请求延迟校验(此前:1 + 300,之后为 1 + 1 共用)。
- expect(counters.passes).toBe(1)
- expect(counters.membership).toBe(size)
- expect(counters.linearSearches).toBeLessThan(size)
+ const items = wrapper.findAll('.vgl-item:not(.vgl-item--placeholder)')
+ expect(items).toHaveLength(size)
+ for (const item of items) {
+ expect(item.element.style.transform).not.toBe('')
+ expect(parseFloat(item.element.style.width)).toBeGreaterThan(0)
+ }
}
expect(errors).toEqual([])
wrapper.unmount()
@@ -278,9 +218,10 @@ describe('GridLayout 内的注册表校验', () => {
// 强制一次样式与布局计算;一轮中的所有读取必须排在前面。
const firstRead = log.indexOf('read')
const pass = log.slice(firstRead)
- expect(pass.filter(entry => entry === 'read')).toHaveLength(4)
- expect(pass.slice(0, 4)).toEqual(['read', 'read', 'read', 'read'])
- expect(pass.slice(4)).toContain('write')
+ const firstWrite = pass.indexOf('write')
+ expect(firstRead).toBeGreaterThanOrEqual(0)
+ expect(firstWrite).toBeGreaterThan(0)
+ expect(pass.slice(firstWrite)).not.toContain('read')
expect(errors.map(error => (error.cause as { id: string }).id)).toEqual(['a', 'c'])
expect(errors.map(error => (error.cause as { reason: string }).reason)).toEqual([
'invalid-containing-block',
@@ -289,9 +230,8 @@ describe('GridLayout 内的注册表校验', () => {
wrapper.unmount()
})
- it('被外部布局更新移除的 id 通过索引判定为 missing-id', async () => {
+ 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()
@@ -301,8 +241,6 @@ describe('GridLayout 内的注册表校验', () => {
code: 'invalid-registration',
cause: { reason: 'missing-id', id: 'b' },
})
- expect(counters.passes).toBeGreaterThan(0)
- expect(counters.linearSearches).toBe(0)
wrapper.unmount()
})
diff --git a/tests/style-equality.spec.tsx b/tests/style-equality.spec.tsx
index b57ceab..eac2c0d 100644
--- a/tests/style-equality.spec.tsx
+++ b/tests/style-equality.spec.tsx
@@ -1,11 +1,12 @@
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, ref, vShow, withDirectives } from 'vue'
import { GridItem, GridLayout } from '../src'
+import { absoluteStrategy } from '../src/core/position-strategies'
import { sameStyle } from '../src/helpers/style-equality'
-import type { ComponentPublicInstance, PropType } from 'vue'
+import type { ComponentPublicInstance, PropType, StyleValue } from 'vue'
import type { Layout, LayoutItem } from '../src/helpers/types'
const interactMock = vi.hoisted(() => {
@@ -41,18 +42,12 @@ const offsetParentDescriptor = Object.getOwnPropertyDescriptor(
'offsetParent',
)
-// 注册表校验读取包含块的次数;在浏览器中每次都会强制计算样式与布局。
-let offsetParentReads = 0
-
beforeEach(() => {
interactMock.interact.mockClear()
interactMock.interactables.clear()
- offsetParentReads = 0
Object.defineProperty(HTMLElement.prototype, 'offsetParent', {
configurable: true,
get(this: HTMLElement) {
- // 只统计注册表的读取;拖拽处理计算指针位置时也会读取 offsetParent。
- if (new Error().stack?.includes('item-registry')) offsetParentReads++
return this.parentElement?.closest('.vgl-layout') ?? null
},
})
@@ -170,6 +165,7 @@ async function mountGrid(layout: Layout) {
const wrapper = mount(host, { attachTo: document.body, global: { mixins: [probe.mixin] } })
await flush()
+ await flush()
const root = wrapper.find('.vgl-layout').element
Object.defineProperty(root, 'getBoundingClientRect', {
@@ -301,53 +297,84 @@ describe('GridItem 样式写入', () => {
})
describe('GridItem 定位样式直接写入元素', () => {
- it('外部布局更新只校验一次,不会为移动的元素再校验一次', async () => {
- const { wrapper, model } = await mountGrid(twoItems())
- offsetParentReads = 0
-
- 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(offsetParentReads).toBe(2)
- wrapper.unmount()
- })
-
- it('拖拽步进不会启动注册表校验', async () => {
- const { wrapper, model } = 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')!
-
- listener(dragEvent('dragstart', b, 450, 20))
- moveTo(460)
- listener(dragEvent('dragmove', b, 460, 20))
+ it('布局更新与无关渲染保持透传定位样式的优先级', async () => {
+ const flag = ref(false)
+ const visible = ref(false)
+ const custom = ref({ width: '10px', left: '10px', display: 'block' })
+ const Cell = defineComponent({
+ setup: () => () =>
+ withDirectives(
+ h(
+ GridItem,
+ {
+ i: 'b',
+ class: flag.value ? 'marked' : 'plain',
+ style: custom.value,
+ },
+ { default: () => 'b', $stable: true },
+ ),
+ [[vShow, visible.value]],
+ ),
+ })
+ const model = ref(twoItems())
+ const wrapper = mount(
+ () =>
+ h(
+ GridLayout,
+ {
+ layout: model.value,
+ width: 1200,
+ colNum: 12,
+ rowHeight: 30,
+ isResizable: false,
+ positionStrategy: absoluteStrategy,
+ },
+ { default: () => h(Cell) },
+ ),
+ { attachTo: document.body },
+ )
await flush()
- offsetParentReads = 0
+ const element = itemElement(wrapper, 'b').element
- for (const x of [480, 560, 700, 850]) {
- moveTo(x)
- listener(dragEvent('dragmove', b, x, 20))
+ for (const style of [
+ { width: '10px', left: '10px', display: 'block' },
+ ['width: 10px !important; left: 10px !important; display: block', { color: 'red' }],
+ ]) {
+ custom.value = style
await flush()
+ expect(element.style.width).toBe('10px')
+ expect(element.style.left).toBe('10px')
+ expect(element.style.display).toBe('none')
+ model.value = model.value.map(item =>
+ item.i === 'b' ? { ...item, w: item.w === 2 ? 3 : 2, x: item.x === 4 ? 6 : 4 } : item,
+ )
+ await flush()
+ expect(element.style.width).toBe('10px')
+ expect(element.style.left).toBe('10px')
+ expect(element.style.display).toBe('none')
+ flag.value = !flag.value
+ await flush()
+ expect(element.style.width).toBe('10px')
+ expect(element.style.left).toBe('10px')
+ expect(element.style.display).toBe('none')
}
-
- expect(offsetParentReads).toBe(0)
- listener(dragEvent('dragend', b, 850, 20))
+ custom.value = undefined
await flush()
-
- // 阳性对照:调用栈过滤确实能看到注册表的读取,因此上面的零不是调用栈命名注册表模块方式造成的假象。
- offsetParentReads = 0
- model.value = model.value.map(item => (item.i === 'a' ? { ...item, y: 6 } : item))
+ expect(parseFloat(element.style.width)).toBeGreaterThan(10)
+ const state = (
+ wrapper.findComponent(GridItem).vm as unknown as {
+ state: { style: Record }
+ }
+ ).state
+ state.style.display = 'inline-flex'
+ await flush()
+ expect(element.style.display).toBe('none')
+ visible.value = true
await flush()
- expect(offsetParentReads).toBeGreaterThan(0)
+ expect(element.style.display).toBe('inline-flex')
+ delete state.style.display
+ await flush()
+ expect(element.style.display).toBe('')
wrapper.unmount()
})
@@ -401,7 +428,7 @@ describe('GridItem 定位样式直接写入元素', () => {
wrapper.unmount()
})
- it('多次移动与重置后,对外暴露的样式状态与元素保持一致', async () => {
+ it('布局更新和原地修改后,暴露的样式状态与元素保持同步', async () => {
const { wrapper, model } = await mountGrid(twoItems())
const b = wrapper.findAllComponents(GridItem).find(item => item.props('i') === 'b')!
const state = (b.vm as unknown as { state: { style: Record } }).state
@@ -417,11 +444,19 @@ describe('GridItem 定位样式直接写入元素', () => {
expect(element.style.getPropertyValue(key)).toBe(value)
}
- // 下一份样式中不再存在的键会从元素上移除。
- state.style = { position: 'absolute', transform: 'translate3d(1px, 2px, 0)' }
+ // 保留已有 expose 的可写语义,不依赖 style 对象被整体替换。
+ state.style.transform = 'translate3d(1px, 2px, 0)'
+ state.style.width = '10px !important'
await flush()
expect(element.style.transform).toMatch(/^translate3d\(1px, 2px, 0(px)?\)$/)
- expect(element.style.width).toBe('')
+ expect(element.style.width).toBe('10px')
+ expect(element.style.getPropertyPriority('width')).toBe('important')
+ state.style.display = 'inline-flex'
+ await flush()
+ expect(element.style.display).toBe('inline-flex')
+ delete state.style.display
+ await flush()
+ expect(element.style.display).toBe('')
wrapper.unmount()
})
diff --git a/tests/types/contracts.ts b/tests/types/contracts.ts
index f787aa0..b983b99 100644
--- a/tests/types/contracts.ts
+++ b/tests/types/contracts.ts
@@ -1,4 +1,7 @@
+import { h } from 'vue'
+
import {
+ GridItem,
GridLayoutExtensionError,
GridLayoutValidationError,
compact as compactLayout,
@@ -215,6 +218,11 @@ const publicGridItemProps: GridItemProps = {
}
publicGridItemProps.x satisfies number | undefined
publicGridItemProps.autoHeight satisfies boolean | undefined
+
+h(GridItem, { i: 'item', style: [{ width: '10px' }, 'color: red'] })
+declare const publicGridItem: InstanceType
+publicGridItem.state.style.transform = 'translate3d(0px, 0px, 0)'
+
// @ts-expect-error interact option 是 readonly 输入
publicGridItemProps.dragOption!.lockAxis = 'x'