openpencil/tests/engine/vue/lifecycle/retention.test.ts
Danila Poyarkov 453b27f15f perf(properties): retain inactive panels safely
Retain one selection-property subtree while frame presets are shown. Suspend opted-in descendant scopes after cleanup, cancel drafts before detachment can blur inputs, close portals, and release previews and pending resource work.

Track focused-element removal with VueUse so WebKit shortcuts resume after input teardown. Cover retained identity, hidden inactivity, binding rollback, async disposal, undo, popup reactivation and browser focus behavior.
2026-09-15 17:01:44 +03:00

213 lines
6.4 KiB
TypeScript

import { expect, test } from 'bun:test'
import { defineComponent, h, KeepAlive, nextTick, ref, watchEffect } from 'vue'
import { createEditor } from '@open-pencil/core/editor'
import { usePropScrub } from '#vue/controls/prop-scrub/use'
import { useUndoBatch } from '#vue/controls/undo-batch/use'
import { provideRetainedActivity } from '#vue/lifecycle/retention/context'
import { createRetainedScopePlugin } from '#vue/lifecycle/retention/plugin'
import { useRetainedPopup } from '#vue/lifecycle/retention/popup'
import { useFontPicker } from '#vue/primitives/FontPicker/useFontPicker'
import { expectDefined } from '#tests/helpers/assert'
import { createTestRenderer, hostNode } from '#tests/helpers/vue/renderer'
async function flushRetention() {
await nextTick()
await nextTick()
}
test('suspends descendant scopes, cancels previews, closes popups and refreshes on activation', async () => {
const editor = createEditor()
const shape = editor.graph.createNode('RECTANGLE', editor.state.currentPageId, { x: 10 })
editor.select([shape.id])
const active = ref(true),
value = ref(0)
let mounts = 0,
renders = 0,
effects = 0
const popup = ref(false)
const Field = defineComponent({
setup() {
mounts++
useRetainedPopup(popup)
const scrub = usePropScrub(editor)
scrub.updateProp([shape], 'x', 50)
watchEffect(() => {
void value.value
effects++
})
return () => {
renders++
return h('span', `${value.value}:${popup.value}`)
}
}
})
const Panel = defineComponent({ setup: () => () => h(Field) })
const root = hostNode()
const app = createTestRenderer()
.createApp({
setup() {
provideRetainedActivity(active)
return () => h(KeepAlive, { max: 1 }, { default: () => (active.value ? h(Panel) : null) })
}
})
.use(createRetainedScopePlugin())
try {
app.mount(root)
popup.value = true
await flushRetention()
active.value = false
await flushRetention()
expect(editor.isInteractiveEditing()).toBe(false)
expect(shape.x).toBe(10)
expect(popup.value).toBe(false)
const before = { renders, effects }
value.value = 42
await flushRetention()
expect({ renders, effects }).toEqual(before)
active.value = true
await flushRetention()
expect(mounts).toBe(1)
expect(renders).toBeGreaterThan(before.renders)
expect(effects).toBe(before.effects + 1)
expect(root.children[0]?.text).toBe('42:false')
expect(popup.value).toBe(false)
} finally {
app.unmount()
editor.dispose()
}
})
test('finishes applied property-list batches and releases their interaction lease', async () => {
const editor = createEditor()
const shape = editor.graph.createNode('RECTANGLE', editor.state.currentPageId, { x: 10 })
const active = ref(true)
const Field = defineComponent({
setup() {
const batch = useUndoBatch(editor.undo, editor.beginInteractiveEdit)
batch.ensure('property', 'Change property')
editor.updateNodeWithUndo(shape.id, { x: 50 })
return () => h('span')
}
})
const app = createTestRenderer()
.createApp({
setup() {
provideRetainedActivity(active)
return () => h(KeepAlive, { max: 1 }, { default: () => (active.value ? h(Field) : null) })
}
})
.use(createRetainedScopePlugin())
try {
app.mount(hostNode())
expect(editor.undo.isBatching).toBe(true)
active.value = false
await flushRetention()
expect(editor.undo.isBatching).toBe(false)
expect(editor.isInteractiveEditing()).toBe(false)
expect(shape.x).toBe(50)
editor.undoAction()
expect(shape.x).toBe(10)
expect(editor.undo.canUndo).toBe(false)
} finally {
app.unmount()
editor.dispose()
}
})
test('discards pending font results on deactivation and disposal, and permits a fresh request', async () => {
const first = Promise.withResolvers<string[]>()
const second = Promise.withResolvers<string[]>()
const active = ref(true)
const state: { picker?: ReturnType<typeof useFontPicker> } = {}
let requests = 0
const Field = defineComponent({
setup() {
const picker = useFontPicker({
modelValue: ref('Inter'),
listFamilies: () => (++requests === 1 ? first.promise : second.promise)
})
useRetainedPopup(picker.open)
state.picker = picker
picker.open.value = true
return () => h('span')
}
})
const app = createTestRenderer()
.createApp({
setup() {
provideRetainedActivity(active)
return () => h(KeepAlive, { max: 1 }, { default: () => (active.value ? h(Field) : null) })
}
})
.use(createRetainedScopePlugin())
try {
app.mount(hostNode())
await flushRetention()
const picker = expectDefined(state.picker, 'font picker')
expect(picker.loading.value).toBe(true)
active.value = false
await flushRetention()
first.resolve(['Stale font'])
await flushRetention()
expect(picker.families.value).toEqual([])
expect(picker.loading.value).toBe(false)
expect(picker.open.value).toBe(false)
active.value = true
await flushRetention()
picker.open.value = true
await flushRetention()
expect(requests).toBe(2)
expect(picker.loading.value).toBe(true)
} finally {
app.unmount()
second.resolve(['Disposed font'])
}
await flushRetention()
expect(state.picker?.families.value).toEqual([])
expect(state.picker?.loading.value).toBe(false)
})
test('an interrupted deactivation does not pause a reactivated or unmounted scope', async () => {
const active = ref(true),
value = ref(0)
let renders = 0
const Field = defineComponent({
setup: () => () => {
renders++
return h('span', String(value.value))
}
})
const app = createTestRenderer()
.createApp({
setup() {
provideRetainedActivity(active)
return () => h(KeepAlive, { max: 1 }, { default: () => (active.value ? h(Field) : null) })
}
})
.use(createRetainedScopePlugin())
try {
app.mount(hostNode())
active.value = false
await nextTick()
active.value = true
await flushRetention()
const before = renders
value.value++
await flushRetention()
expect(renders).toBe(before + 1)
active.value = false
await nextTick()
} finally {
app.unmount()
}
await flushRetention()
const before = renders
value.value++
await flushRetention()
expect(renders).toBe(before)
})