perf(glass): complete phase three optimization (#580)

This commit is contained in:
InfinityPacer
2026-07-23 20:26:28 +08:00
committed by GitHub
parent 31caf8f5f8
commit 1bf6c1f1d8
12 changed files with 277 additions and 89 deletions

View File

@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { usePreferredReducedMotion } from '@vueuse/core'
import { useTheme } from 'vuetify'
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
import api, { type ConnectionAwareRequestConfig } from '@/api'
@@ -30,7 +31,6 @@ import { commitPreloadedBackgroundRotation } from '@/utils/backgroundRotation'
const LOGIN_WALLPAPER_ROUTE = '/login'
const BACKGROUND_CROSSFADE_DURATION_MS = 1500
const MEDIA_DENSE_OPTICAL_DEFER_MS = 1_600
// 生效主题
const vuetifyTheme = useTheme()
@@ -75,12 +75,13 @@ const activeImageIndex = ref(0)
const previousImageIndex = ref<number | null>(null)
const isBackgroundCrossfading = ref(false)
const { allowsDecorativeMotion, isSuspended: isRenderThrottled, state: appActivityState } = useAppActivityLifecycle()
const preferredMotion = usePreferredReducedMotion()
// 壁纸轮播同时服从应用活动状态与系统动态效果偏好。
const allowsBackgroundRotation = computed(() => allowsDecorativeMotion.value && preferredMotion.value !== 'reduce')
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
const isGlassTheme = computed(() => globalTheme.name.value === 'glass')
const effectiveGlassSettings = useEffectiveGlassSettings()
const isInitialRouteReady = ref(false)
const isGlassOpticalLayerDeferred = ref(true)
let glassOpticalLayerDeferTimer: number | null = null
const isBackdropTheme = computed(() => isTransparentTheme.value || isGlassTheme.value)
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
const shouldUseTransparentBackgroundTreatment = computed(() => Boolean(isLogin.value) && isTransparentTheme.value)
@@ -100,7 +101,7 @@ const shouldRenderGlassOpticalLayer = computed(
() =>
isGlassTheme.value &&
effectiveGlassSettings.value.glassQuality !== 'css' &&
!isGlassOpticalLayerDeferred.value &&
isInitialRouteReady.value &&
!isRenderThrottled.value &&
Boolean(activeBackgroundImage.value),
)
@@ -139,35 +140,6 @@ function handleTransparencySettingsChanged(event: Event) {
applyTransparentBackgroundSettings()
/** 推荐页高质量光学层让首屏海报优先完成解码与合成。 */
watch(
() => [route.fullPath, effectiveGlassSettings.value.glassQuality, isInitialRouteReady.value] as const,
([, quality, routeReady]) => {
if (glassOpticalLayerDeferTimer !== null) {
window.clearTimeout(glassOpticalLayerDeferTimer)
glassOpticalLayerDeferTimer = null
}
// 首次导航完成前 route 仍可能是重定向来源,禁止 renderer 按错误页面短暂挂载。
if (!routeReady) {
isGlassOpticalLayerDeferred.value = true
return
}
if (route.path !== '/recommend' || quality !== 'high') {
isGlassOpticalLayerDeferred.value = false
return
}
isGlassOpticalLayerDeferred.value = true
glassOpticalLayerDeferTimer = window.setTimeout(() => {
isGlassOpticalLayerDeferred.value = false
glassOpticalLayerDeferTimer = null
}, MEDIA_DENSE_OPTICAL_DEFER_MS)
},
{ immediate: true },
)
void router.isReady().then(() => {
isInitialRouteReady.value = true
})
@@ -412,7 +384,7 @@ async function fetchBackgroundImages() {
// 背景图片轮换函数
function rotateBackgroundImage() {
if (!allowsDecorativeMotion.value) return
if (!allowsBackgroundRotation.value) return
if (backgroundImages.value.length > 1) {
// 计算下一个图片索引
@@ -420,7 +392,7 @@ function rotateBackgroundImage() {
const requestVersion = ++backgroundRotationVersion
void commitPreloadedBackgroundRotation({
canCommit: () => allowsDecorativeMotion.value && requestVersion === backgroundRotationVersion,
canCommit: () => allowsBackgroundRotation.value && requestVersion === backgroundRotationVersion,
commit: () => activateBackgroundImage(nextIndex),
preload: () => preloadImage(backgroundImages.value[nextIndex]),
})
@@ -437,7 +409,7 @@ function stopBackgroundRotation() {
function startBackgroundRotation() {
stopBackgroundRotation()
if (allowsDecorativeMotion.value && backgroundImages.value.length > 1) {
if (allowsBackgroundRotation.value && backgroundImages.value.length > 1) {
// 使用优化的定时器管理器,后台时自动暂停
addBackgroundTimer(
'background-rotation',
@@ -451,10 +423,10 @@ function startBackgroundRotation() {
}
}
watch(appActivityState, state => {
watch(allowsBackgroundRotation, allowsRotation => {
resetBackgroundCrossfade()
if (state === 'active') {
if (allowsRotation) {
startBackgroundRotation()
} else {
stopBackgroundRotation()
@@ -655,7 +627,6 @@ onMounted(async () => {
})
onUnmounted(() => {
if (glassOpticalLayerDeferTimer !== null) window.clearTimeout(glassOpticalLayerDeferTimer)
// 清除背景轮换定时器
stopBackgroundLoading()
if (authenticatedStateTimer) {

View File

@@ -464,6 +464,7 @@ onBeforeUnmount(() => {
class="app-hover-lift-card outline-none ring-gray-500 media-card"
:class="{
'app-hover-lift-card--hovering': isMediaCardActive(hover.isHovering),
'media-card--image-loaded': isImageLoaded,
'ring-1': isImageLoaded,
}"
@click.stop="handleMediaCardClick(hover.isHovering)"

View File

@@ -520,8 +520,10 @@ onMounted(() => {
<input
ref="searchWordInput"
v-model="searchWord"
id="global-media-search"
type="text"
class="search-native-input"
:aria-label="t('dialog.searchBar.searchPlaceholder')"
:placeholder="t('dialog.searchBar.searchPlaceholder')"
@keydown.enter="searchMedia('media')"
@keydown.escape.stop="closeSearch"
@@ -539,8 +541,10 @@ onMounted(() => {
<input
ref="searchWordInput"
v-model="searchWord"
id="global-media-search"
type="text"
class="search-native-input"
:aria-label="t('dialog.searchBar.searchPlaceholder')"
:placeholder="t('dialog.searchBar.searchPlaceholder')"
@keydown.enter="searchMedia('media')"
@keydown.escape.stop="closeSearch"

View File

@@ -43,6 +43,9 @@ describe('SearchBarDialog media source selection', () => {
const { router } = await renderSearchBar()
const input = await screen.findByPlaceholderText('搜索电影、剧集以及更多...')
expect(input.getAttribute('id')).toBe('global-media-search')
expect(input.getAttribute('aria-label')).toBe('搜索电影、剧集以及更多...')
await user.type(input, '流浪地球{Enter}')
await waitFor(() => {

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { findNearestScrollTarget, invalidateScrollTargetCache, type ScrollTarget } from '@/utils/scrollTarget'
import type { ComponentPublicInstance } from 'vue'
type ItemKey = string | number
type ScrollTarget = Window | HTMLElement
const props = withDefaults(
defineProps<{
@@ -513,19 +513,7 @@ function getItemRef(key: ItemKey) {
}
function findScrollTarget(): ScrollTarget {
let parent = containerRef.value?.parentElement ?? null
while (parent && parent !== document.body && parent !== document.documentElement) {
const overflowY = window.getComputedStyle(parent).overflowY
if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') {
return parent
}
parent = parent.parentElement
}
return window
return findNearestScrollTarget(containerRef.value)
}
function addScrollListener(target: ScrollTarget) {
@@ -552,6 +540,11 @@ function refreshScrollTarget() {
addScrollListener(scrollTarget)
}
function handleWindowResize() {
invalidateScrollTargetCache()
queueLayoutSync()
}
function syncLayoutWidth() {
const element = trackRef.value
@@ -780,10 +773,7 @@ function invalidateMeasurementsForLayoutChange() {
const nextColumnCount = columnCount.value
const nextColumnWidth = columnWidth.value
if (
lastMeasuredColumnCount === nextColumnCount &&
Math.abs(lastMeasuredColumnWidth - nextColumnWidth) < 1
) {
if (lastMeasuredColumnCount === nextColumnCount && Math.abs(lastMeasuredColumnWidth - nextColumnWidth) < 1) {
return
}
@@ -817,7 +807,7 @@ onMounted(() => {
})
}
window.addEventListener('resize', queueLayoutSync, { passive: true })
window.addEventListener('resize', handleWindowResize, { passive: true })
queueLayoutSync()
})
@@ -840,7 +830,7 @@ onUnmounted(() => {
removeScrollListener(scrollTarget)
scrollTarget = null
window.removeEventListener('resize', queueLayoutSync)
window.removeEventListener('resize', handleWindowResize)
resizeObserver?.disconnect()
resizeObserver = null
itemResizeObserver?.disconnect()
@@ -881,13 +871,10 @@ watch(
},
)
watch(
[columnCount, columnWidth],
() => {
invalidateMeasurementsForLayoutChange()
queueViewportSync()
},
)
watch([columnCount, columnWidth], () => {
invalidateMeasurementsForLayoutChange()
queueViewportSync()
})
watch(
[() => props.scrollToIndex, () => props.items.length, columnCount],

View File

@@ -167,4 +167,78 @@ describe('glass optical surface discovery', () => {
scope.stop()
})
it('releases renderer resources before restoring a single active instance', async () => {
const three = await import('three')
const canvas = document.createElement('canvas')
const active = ref(true)
const scope = effectScope()
let frameId = 0
const requestFrame = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => ++frameId)
const cancelFrame = vi.spyOn(window, 'cancelAnimationFrame')
const rendererDispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose')
const contextLoss = vi.spyOn(three.WebGLRenderer.prototype, 'forceContextLoss')
const resizeDisconnect = vi.spyOn(ResizeObserverMock.prototype, 'disconnect')
const mutationDisconnect = vi.spyOn(MutationObserver.prototype, 'disconnect')
const addWindowListener = vi.spyOn(window, 'addEventListener')
const addDocumentListener = vi.spyOn(document, 'addEventListener')
const addCanvasListener = vi.spyOn(canvas, 'addEventListener')
const countListenerAdds = (calls: readonly (readonly unknown[])[], eventName: string) =>
calls.filter(([event]) => String(event) === eventName).length
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active,
appearance: ref('clear'),
canvas: ref(canvas),
quality: ref('high'),
routeKey: ref('/recommend'),
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
expect(requestFrame).toHaveBeenCalled()
expect(countListenerAdds(addWindowListener.mock.calls, 'pointermove')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'scroll')).toBe(1)
expect(countListenerAdds(addDocumentListener.mock.calls, 'visibilitychange')).toBe(1)
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(1)
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextrestored')).toBe(1)
active.value = false
await nextTick()
expect(rendererDispose).toHaveBeenCalledTimes(1)
expect(contextLoss).toHaveBeenCalledTimes(1)
expect(resizeDisconnect).toHaveBeenCalledTimes(1)
expect(mutationDisconnect).toHaveBeenCalledTimes(1)
expect(cancelFrame).toHaveBeenCalledTimes(2)
addWindowListener.mockClear()
addDocumentListener.mockClear()
addCanvasListener.mockClear()
active.value = true
await nextTick()
await vi.waitFor(() => expect(countListenerAdds(addWindowListener.mock.calls, 'pointermove')).toBe(1))
expect(renderer?.state.value).toBe('ready')
expect(countListenerAdds(addWindowListener.mock.calls, 'pointermove')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'scroll')).toBe(1)
expect(countListenerAdds(addDocumentListener.mock.calls, 'visibilitychange')).toBe(1)
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(1)
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextrestored')).toBe(1)
active.value = false
await nextTick()
expect(rendererDispose).toHaveBeenCalledTimes(2)
expect(contextLoss).toHaveBeenCalledTimes(2)
expect(resizeDisconnect).toHaveBeenCalledTimes(2)
expect(mutationDisconnect).toHaveBeenCalledTimes(2)
expect(cancelFrame).toHaveBeenCalledTimes(4)
scope.stop()
})
})

View File

@@ -379,12 +379,16 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
resources.uniforms.uCoverScale.value.set(cover.x, cover.y)
}
function getRenderProfile(routeKey = toValue(options.routeKey)) {
return getGlassOpticalRenderProfile(toValue(options.quality), routeKey)
}
function resizeRenderer() {
if (!resources) return
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const profile = getGlassOpticalRenderProfile(toValue(options.quality), toValue(options.routeKey))
const profile = getRenderProfile()
const buffer = getGlassOpticalBufferSize(viewportWidth, viewportHeight, viewportWidth <= 600, profile.bufferQuality)
resources.renderer.setSize(buffer.width, buffer.height, false)
resources.uniforms.uViewportSize.value.set(viewportWidth, viewportHeight)
@@ -392,6 +396,16 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
scheduleSurfaceUpdate()
}
function profileRequiresTextureReload(
previousProfile: ReturnType<typeof getGlassOpticalRenderProfile>,
nextProfile: ReturnType<typeof getGlassOpticalRenderProfile>,
) {
return (
previousProfile.textureLimit !== nextProfile.textureLimit ||
previousProfile.textureSource !== nextProfile.textureSource
)
}
function handlePointerMove(event: PointerEvent) {
if (!resources || matchMedia('(prefers-reduced-motion: reduce)').matches) return
if (
@@ -555,7 +569,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
async function loadWallpaper(url: string, version: number) {
if (!resources || !three || !url) return
const profile = getGlassOpticalRenderProfile(toValue(options.quality), toValue(options.routeKey))
const profile = getRenderProfile()
const shouldUseProceduralTexture =
profile.textureSource === 'procedural' ||
(profile.textureSource === 'auto' && !canUseGlassWallpaperTexture(url, window.location.href))
@@ -784,17 +798,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
watch(
() => toValue(options.routeKey),
async (routeKey, previousRouteKey) => {
const previousProfile = getRenderProfile(previousRouteKey ?? '')
await nextTick()
if (resources) {
const previousProfile = getGlassOpticalRenderProfile(toValue(options.quality), previousRouteKey ?? '')
const nextProfile = getGlassOpticalRenderProfile(toValue(options.quality), routeKey)
const nextProfile = getRenderProfile(routeKey)
resizeRenderer()
if (
previousProfile.bufferQuality !== nextProfile.bufferQuality ||
previousProfile.textureLimit !== nextProfile.textureLimit ||
previousProfile.textureSource !== nextProfile.textureSource
) {
if (profileRequiresTextureReload(previousProfile, nextProfile)) {
const version = ++loadVersion
setGlassRendererState(state, 'loading')

View File

@@ -64,6 +64,7 @@ html[data-theme='glass'] {
--glass-blur: 22px;
--glass-blur-raised: 32px;
--glass-saturate: 145%;
--glass-content-reveal-duration: 160ms;
--glass-motion: 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
--app-card-rest-shadow: var(--glass-shadow);
--app-card-hover-shadow: var(--glass-shadow-hover);
@@ -322,6 +323,21 @@ html[data-theme='glass'] {
background-color: var(--glass-surface) !important;
}
// 海报完成绘制后已完全遮住卡片底面,释放不可见的实时背景采样。
.media-card.media-card--image-loaded {
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
}
.media-card .v-img__img {
opacity: 0;
transition: opacity var(--glass-content-reveal-duration) cubic-bezier(0.2, 0.8, 0.2, 1);
}
.media-card--image-loaded .v-img__img {
opacity: 1;
}
.v-toolbar {
border-color: var(--glass-border);
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
@@ -336,10 +352,7 @@ html[data-theme='glass'] {
backdrop-filter: var(--glass-dashboard-backdrop-filter);
}
:where(
.layout-navbar,
.Vue-Toastification__toast
) {
:where(.layout-navbar, .Vue-Toastification__toast) {
border-color: var(--glass-border-raised) !important;
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
backdrop-filter: var(--glass-raised-backdrop-filter);
@@ -896,7 +909,19 @@ html[data-theme='glass'] {
) {
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
background-color: rgba(11, 19, 34, 94%) !important;
background-color: rgb(11, 19, 34) !important;
}
.layout-vertical-nav::before {
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
background-color: rgb(11, 19, 34) !important;
}
.playing-card__percent {
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
background-color: rgba(11, 19, 34, 88%) !important;
}
}
@@ -907,6 +932,10 @@ html[data-theme='glass'] {
.background-image {
transition-duration: 0.01ms !important;
}
.media-card .v-img__img {
transition-duration: 0.01ms;
}
}
}
@@ -1129,6 +1158,12 @@ html[data-theme='glass'][data-glass-appearance='frosted'] body[data-theme='glass
}
}
html[data-theme='glass'] body[data-theme='glass'] .native-login-field:focus-within {
border-color: rgba(var(--v-theme-primary), 70%);
background: var(--glass-control-prominent-focus);
box-shadow: var(--glass-control-prominent-focus-shadow);
}
@media (prefers-reduced-motion: reduce) {
.glass-optical-layer {
transition: none;

View File

@@ -22,15 +22,20 @@ describe('glass optics geometry', () => {
expect(getGlassCoverScale(900, 1600, 2400, 1600)).toEqual({ x: 0.375, y: 1 })
})
it('keeps high quality optics while reducing the media-dense recommendation budget', () => {
it('keeps the selected optical quality on every route', () => {
expect(getGlassOpticalRenderProfile('high', '/dashboard')).toEqual({
bufferQuality: 'high',
textureLimit: 3072,
textureSource: 'wallpaper',
})
expect(getGlassOpticalRenderProfile('high', '/recommend?source=tmdb')).toEqual({
bufferQuality: 'balanced',
textureLimit: 2048,
bufferQuality: 'high',
textureLimit: 3072,
textureSource: 'wallpaper',
})
expect(getGlassOpticalRenderProfile('high', '/subscribe/movie')).toEqual({
bufferQuality: 'high',
textureLimit: 3072,
textureSource: 'wallpaper',
})
expect(getGlassOpticalRenderProfile('balanced', '/dashboard')).toEqual({

View File

@@ -0,0 +1,54 @@
import { findNearestScrollTarget, invalidateScrollTargetCache } from '@/utils/scrollTarget'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('findNearestScrollTarget', () => {
beforeEach(() => {
invalidateScrollTargetCache()
document.body.innerHTML = ''
})
afterEach(() => {
vi.restoreAllMocks()
})
it('returns the nearest scrollable ancestor', () => {
const scrollable = document.createElement('div')
const wrapper = document.createElement('div')
const grid = document.createElement('div')
scrollable.style.overflowY = 'auto'
scrollable.append(wrapper)
wrapper.append(grid)
document.body.append(scrollable)
expect(findNearestScrollTarget(grid)).toBe(scrollable)
})
it('reuses ancestor results across sibling grids', () => {
const getComputedStyle = vi.spyOn(window, 'getComputedStyle')
const wrapper = document.createElement('div')
const firstGrid = document.createElement('div')
const secondGrid = document.createElement('div')
wrapper.append(firstGrid, secondGrid)
document.body.append(wrapper)
expect(findNearestScrollTarget(firstGrid)).toBe(window)
const callsAfterFirstGrid = getComputedStyle.mock.calls.length
expect(findNearestScrollTarget(secondGrid)).toBe(window)
expect(getComputedStyle).toHaveBeenCalledTimes(callsAfterFirstGrid)
})
it('recomputes targets after cache invalidation', () => {
const wrapper = document.createElement('div')
const grid = document.createElement('div')
wrapper.append(grid)
document.body.append(wrapper)
expect(findNearestScrollTarget(grid)).toBe(window)
wrapper.style.overflowY = 'auto'
invalidateScrollTargetCache()
expect(findNearestScrollTarget(grid)).toBe(wrapper)
})
})

View File

@@ -24,7 +24,7 @@ export interface GlassOpticalBufferSize {
}
export interface GlassOpticalRenderProfile {
/** 实际内部缓冲档位;媒体密集场景可保留高质量光学但降低合成分辨率。 */
/** 光学层内部缓冲使用的质量档位。 */
bufferQuality: GlassOpticalQuality
/** 活动壁纸进入 GPU 前的最长边限制。 */
textureLimit: number
@@ -32,16 +32,14 @@ export interface GlassOpticalRenderProfile {
textureSource: 'auto' | 'procedural' | 'wallpaper'
}
/** 质量与场景分配合成预算,避免推荐页海报解码与高分辨率光学层争抢资源。 */
/** 质量决定合成缓冲与纹理上限;路由只切换纹理来源,不改变质量档位。 */
export function getGlassOpticalRenderProfile(
quality: GlassOpticalQuality,
routeKey: string,
): GlassOpticalRenderProfile {
const mediaDenseRoute = routeKey.startsWith('/recommend')
return {
bufferQuality: quality === 'high' && !mediaDenseRoute ? 'high' : 'balanced',
textureLimit: quality === 'high' && !mediaDenseRoute ? 3072 : 2048,
bufferQuality: quality,
textureLimit: quality === 'high' ? 3072 : 2048,
textureSource: routeKey.startsWith('/login') ? 'auto' : 'wallpaper',
}
}

46
src/utils/scrollTarget.ts Normal file
View File

@@ -0,0 +1,46 @@
export type ScrollTarget = Window | HTMLElement
let targetCache = new WeakMap<HTMLElement, ScrollTarget>()
/**
* 清除祖先滚动容器缓存。响应式布局或 overlay 状态改变后必须重新解析。
*/
export function invalidateScrollTargetCache() {
targetCache = new WeakMap<HTMLElement, ScrollTarget>()
}
function isScrollableOverflow(overflowY: string) {
return overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay'
}
/**
* 解析元素最近的纵向滚动容器,并让同一祖先链上的网格复用样式查询结果。
*/
export function findNearestScrollTarget(element: HTMLElement | null): ScrollTarget {
if (!element) return window
let parent = element.parentElement
const visited: HTMLElement[] = []
let target: ScrollTarget = window
while (parent && parent !== document.body && parent !== document.documentElement) {
const cachedTarget = targetCache.get(parent)
if (cachedTarget) {
target = cachedTarget
break
}
visited.push(parent)
if (isScrollableOverflow(window.getComputedStyle(parent).overflowY)) {
target = parent
break
}
parent = parent.parentElement
}
visited.forEach(ancestor => targetCache.set(ancestor, target))
return target
}