From 2fc981e77f96104fd0bb6f48761289c61acf3ce3 Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Mon, 27 Jul 2026 22:34:01 +0800 Subject: [PATCH] fix(web): avoid forced layouts in mouse glow discovery MouseGlowService measured every composed-path ancestor while resolving a glow surface, potentially forcing two synchronous layouts per element. It then measured the selected surface and ordinary stacking host again. This could enter Blink flex layout repeatedly while session content was mounting, eventually causing a WebView2 STATUS_ACCESS_VIOLATION. Detect candidates from styles and semantics, measure only the selected surface, and keep normal overlays fixed under document.body. Preserve local positioning for actual floating layers and reuse an existing rect when the floating surface is its own host. The regression originated in 1ec22139 (#1743). --- .../mouse-glow/core/MouseGlowService.test.ts | 60 +++++++++++++++++-- .../mouse-glow/core/MouseGlowService.ts | 38 +++--------- 2 files changed, 63 insertions(+), 35 deletions(-) diff --git a/src/web-ui/src/infrastructure/mouse-glow/core/MouseGlowService.test.ts b/src/web-ui/src/infrastructure/mouse-glow/core/MouseGlowService.test.ts index 929e096d9f..c7fec2de42 100644 --- a/src/web-ui/src/infrastructure/mouse-glow/core/MouseGlowService.test.ts +++ b/src/web-ui/src/infrastructure/mouse-glow/core/MouseGlowService.test.ts @@ -112,6 +112,48 @@ describe('MouseGlowService', () => { surface.remove(); }); + it('only reads geometry for the selected surface', () => { + const surface = document.createElement('div'); + surface.className = 'workspace-card'; + surface.style.border = '1px solid black'; + surface.style.borderRadius = '8px'; + surface.style.background = 'black'; + const readSurfaceGeometry = vi.fn(() => ({ + bottom: 128, + height: 80, + left: 20, + right: 220, + top: 48, + width: 200, + x: 20, + y: 48, + toJSON: () => ({}), + })); + surface.getBoundingClientRect = readSurfaceGeometry; + const wrapper = document.createElement('div'); + const content = document.createElement('code'); + const readWrapperGeometry = vi.fn(); + const readContentGeometry = vi.fn(); + wrapper.getBoundingClientRect = readWrapperGeometry; + content.getBoundingClientRect = readContentGeometry; + wrapper.appendChild(content); + surface.appendChild(wrapper); + document.body.appendChild(surface); + service.initialize(); + + content.dispatchEvent(new MouseEvent('pointermove', { + bubbles: true, + clientX: 72, + clientY: 68, + })); + nextFrame?.(0); + + expect(readContentGeometry).not.toHaveBeenCalled(); + expect(readWrapperGeometry).not.toHaveBeenCalled(); + expect(readSurfaceGeometry).toHaveBeenCalledTimes(1); + surface.remove(); + }); + it('keeps the glow up until the frame resolves where the pointer went', () => { const surface = document.createElement('div'); surface.setAttribute('data-mouse-glow-surface', ''); @@ -249,6 +291,8 @@ describe('MouseGlowService', () => { const stackingHost = document.createElement('div'); stackingHost.style.position = 'relative'; stackingHost.style.zIndex = '1'; + const readStackingHostGeometry = vi.fn(); + stackingHost.getBoundingClientRect = readStackingHostGeometry; const surface = document.createElement('div'); surface.setAttribute('data-mouse-glow-surface', ''); surface.getBoundingClientRect = () => ({ @@ -295,7 +339,9 @@ describe('MouseGlowService', () => { const overlay = document.getElementById('bitfun-mouse-glow-overlay'); expect(overlay?.hasAttribute('data-active')).toBe(true); - expect(overlay?.parentElement).toBe(stackingHost); + expect(overlay?.parentElement).toBe(document.body); + expect(overlay?.hasAttribute('data-local-position')).toBe(false); + expect(readStackingHostGeometry).not.toHaveBeenCalled(); expect(Number(window.getComputedStyle(listbox).zIndex)).toBeGreaterThan(49); option.dispatchEvent(new MouseEvent('pointermove', { @@ -359,7 +405,7 @@ describe('MouseGlowService', () => { transformedHost.style.position = 'absolute'; transformedHost.style.zIndex = '100'; transformedHost.style.transform = 'translateX(-50%)'; - transformedHost.getBoundingClientRect = () => ({ + const readTransformedHostGeometry = vi.fn(() => ({ bottom: 700, height: 200, left: 100, @@ -369,7 +415,8 @@ describe('MouseGlowService', () => { x: 100, y: 500, toJSON: () => ({}), - }); + })); + transformedHost.getBoundingClientRect = readTransformedHostGeometry; const inputSurface = document.createElement('div'); inputSurface.setAttribute('data-mouse-glow-surface', ''); inputSurface.style.border = '1px solid black'; @@ -400,11 +447,12 @@ describe('MouseGlowService', () => { nextFrame?.(0); const overlay = document.getElementById('bitfun-mouse-glow-overlay'); - expect(overlay?.parentElement).toBe(transformedHost); - expect(overlay?.hasAttribute('data-local-position')).toBe(true); - expect(overlay?.style.transform).toBe('translate3d(20px, 40px, 0)'); + expect(overlay?.parentElement).toBe(document.body); + expect(overlay?.hasAttribute('data-local-position')).toBe(false); + expect(overlay?.style.transform).toBe('translate3d(120px, 540px, 0)'); expect(overlay?.style.getPropertyValue('--mouse-glow-local-x')).toBe('60px'); expect(overlay?.style.getPropertyValue('--mouse-glow-local-y')).toBe('20px'); + expect(readTransformedHostGeometry).not.toHaveBeenCalled(); transformedHost.remove(); }); diff --git a/src/web-ui/src/infrastructure/mouse-glow/core/MouseGlowService.ts b/src/web-ui/src/infrastructure/mouse-glow/core/MouseGlowService.ts index 0a8ee16129..5188bca00c 100644 --- a/src/web-ui/src/infrastructure/mouse-glow/core/MouseGlowService.ts +++ b/src/web-ui/src/infrastructure/mouse-glow/core/MouseGlowService.ts @@ -279,7 +279,11 @@ export class MouseGlowService { overlayHost, style, ); - const overlayPosition = this.getOverlayPosition(geometry, overlayHost); + const overlayPosition = this.getOverlayPosition( + geometry, + overlayHost, + overlayHost === surface ? rect : null, + ); this.activeSurface = surface; overlay.toggleAttribute('data-divider', dividerGeometry !== null); overlay.toggleAttribute('data-local-position', overlayPosition.isLocal); @@ -319,6 +323,7 @@ export class MouseGlowService { private getOverlayPosition( geometry: OverlayGeometry, host: HTMLElement, + knownHostRect: DOMRect | null, ): { isLocal: boolean; left: number; top: number } { if (host === document.body) { return { isLocal: false, left: geometry.left, top: geometry.top }; @@ -329,7 +334,7 @@ export class MouseGlowService { return { isLocal: false, left: geometry.left, top: geometry.top }; } - const hostRect = host.getBoundingClientRect(); + const hostRect = knownHostRect ?? host.getBoundingClientRect(); const borderLeftWidth = parseFloat(hostStyle.borderLeftWidth) || 0; const borderTopWidth = parseFloat(hostStyle.borderTopWidth) || 0; return { @@ -408,11 +413,6 @@ export class MouseGlowService { return false; } - const rect = element.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) { - return false; - } - if (!this.isVisibleElement(style)) { return false; } @@ -441,7 +441,6 @@ export class MouseGlowService { return false; } - const rect = element.getBoundingClientRect(); if (!this.isVisibleElement(style)) { return false; } @@ -451,7 +450,7 @@ export class MouseGlowService { return true; } - return this.hasDividerSemantics(element) && this.isLineLikeGeometry(rect); + return this.hasDividerSemantics(element); } private getDividerGeometry( @@ -485,7 +484,7 @@ export class MouseGlowService { } } - if (this.hasDividerSemantics(element)) { + if (this.hasDividerSemantics(element) && this.isLineLikeGeometry(rect)) { return { height: Math.max(rect.height, 1), left: rect.left, @@ -557,9 +556,6 @@ export class MouseGlowService { if (this.isFloatingLayer(current)) { return current; } - if (this.createsStackingContext(current)) { - return current; - } current = current.parentElement; } return document.body; @@ -585,22 +581,6 @@ export class MouseGlowService { ); } - private createsStackingContext(element: HTMLElement): boolean { - const style = window.getComputedStyle(element); - const positionedWithZIndex = - style.position !== 'static' && style.zIndex !== 'auto'; - - return ( - positionedWithZIndex - || style.position === 'fixed' - || style.position === 'sticky' - || style.isolation === 'isolate' - || (style.opacity !== '' && style.opacity !== '1') - || (style.mixBlendMode !== '' && style.mixBlendMode !== 'normal') - || style.willChange.includes('opacity') - ); - } - private isVisibleElement(style: CSSStyleDeclaration): boolean { return ( style.display !== 'none'