Skip to content

Commit fb530fd

Browse files
committed
fix(core): survive secondary window close and avoid hover flicker
- create the hover host in the document it is shown in and never adopt it across documents; cancel the hover on pagehide of the hosting window and guard against closed windows, so hovers no longer break (or crash the Electron renderer) after closing a secondary window with an open hover - keep the hover host hidden until it has been positioned: the popover was briefly visible at (0,0), covering tabs at the top-left of a secondary window and kicking them out of the hover chain, which retriggered mouseenter hovers in an endless show/hide loop (heavy flicker) - do not let a superseded render reposition, reveal, or leak css classes into the hover that replaced it
1 parent b3dc303 commit fb530fd

2 files changed

Lines changed: 263 additions & 52 deletions

File tree

packages/core/src/browser/hover-service.spec.ts

Lines changed: 183 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -26,61 +26,92 @@ import { OpenerService } from './opener-service';
2626

2727
disableJSDOM();
2828

29+
/* eslint-disable @typescript-eslint/no-explicit-any */
30+
2931
describe('HoverService', () => {
3032
let container: Container;
3133
let hoverService: HoverService;
34+
let originalMatches: (selectors: string) => boolean;
3235

3336
before(() => {
3437
disableJSDOM = enableJSDOM();
3538
// The hover service positions its host after waiting for an animation frame.
3639
// JSDOM (without pretendToBeVisual) does not provide requestAnimationFrame.
37-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
3840
(global as any).requestAnimationFrame = (cb: FrameRequestCallback) => setTimeout(cb, 0);
41+
// JSDOM implements neither the Popover API (showPopover/hidePopover) nor the
42+
// `:popover-open` pseudo-class: stub them, tracking the open state in an attribute.
43+
const elementPrototype = window.HTMLElement.prototype as any;
44+
elementPrototype.showPopover = function (this: HTMLElement): void { this.setAttribute('data-test-popover-open', 'true'); };
45+
elementPrototype.hidePopover = function (this: HTMLElement): void { this.removeAttribute('data-test-popover-open'); };
46+
originalMatches = elementPrototype.matches;
47+
elementPrototype.matches = function (this: HTMLElement, selectors: string): boolean {
48+
return selectors === ':popover-open' ? this.hasAttribute('data-test-popover-open') : originalMatches.call(this, selectors);
49+
};
3950
});
4051

4152
after(() => {
42-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
53+
const elementPrototype = window.HTMLElement.prototype as any;
54+
delete elementPrototype.showPopover;
55+
delete elementPrototype.hidePopover;
56+
elementPrototype.matches = originalMatches;
4357
delete (global as any).requestAnimationFrame;
4458
disableJSDOM();
4559
});
4660

4761
beforeEach(() => {
4862
container = new Container();
4963
container.bind(HoverService).toSelf().inSingletonScope();
50-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
5164
container.bind(PreferenceService).toConstantValue({ get: () => 0 } as any);
52-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
5365
container.bind(CoreMarkdownRenderer).toConstantValue({ render: () => ({ element: document.createElement('div'), dispose: () => { } }) } as any);
54-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
5566
container.bind(OpenerService).toConstantValue({} as any);
5667
hoverService = container.get(HoverService);
57-
stubPopoverApi(hoverService);
5868
});
5969

6070
afterEach(() => {
6171
hoverService.cancelHover();
6272
});
6373

64-
/**
65-
* JSDOM implements neither the Popover API (showPopover/hidePopover) nor the
66-
* `:popover-open` pseudo-class, so stub them on the service's host element.
67-
*/
68-
function stubPopoverApi(service: HoverService): void {
69-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
70-
const host: HTMLElement = (service as any).hoverHost;
71-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
72-
(host as any).showPopover = () => { };
73-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
74-
(host as any).hidePopover = () => { };
75-
const originalMatches = host.matches.bind(host);
76-
host.matches = (selectors: string) => selectors === ':popover-open' ? false : originalMatches(selectors);
77-
}
78-
7974
function waitForHover(): Promise<void> {
8075
// hover delay (0ms timeout) + animation frame (0ms timeout stub)
8176
return new Promise(resolve => setTimeout(resolve, 20));
8277
}
8378

79+
interface FakeSecondaryWindow {
80+
secondaryDocument: Document;
81+
fireEvent(type: string): void;
82+
}
83+
84+
/**
85+
* Creates a document simulating one hosted in a secondary window: unlike a document from
86+
* `createHTMLDocument`, it has a `defaultView` window on which the hover service can listen
87+
* for the window going away.
88+
*/
89+
function createSecondaryWindowDocument(options?: { closed?: boolean }): FakeSecondaryWindow {
90+
const secondaryDocument = document.implementation.createHTMLDocument('secondary window');
91+
const listeners = new Map<string, EventListener[]>();
92+
const fakeWindow = {
93+
closed: options?.closed ?? false,
94+
requestAnimationFrame: (cb: FrameRequestCallback) => setTimeout(cb, 0),
95+
addEventListener: (type: string, listener: EventListener) => {
96+
const forType = listeners.get(type) ?? [];
97+
forType.push(listener);
98+
listeners.set(type, forType);
99+
},
100+
removeEventListener: (type: string, listener: EventListener) => {
101+
const forType = listeners.get(type);
102+
const index = forType?.indexOf(listener) ?? -1;
103+
if (forType && index > -1) {
104+
forType.splice(index, 1);
105+
}
106+
}
107+
};
108+
Object.defineProperty(secondaryDocument, 'defaultView', { value: fakeWindow, configurable: true });
109+
return {
110+
secondaryDocument,
111+
fireEvent: type => [...(listeners.get(type) ?? [])].forEach(listener => listener({ type } as Event))
112+
};
113+
}
114+
84115
it('renders the hover in the document of the target element', async () => {
85116
const target = document.createElement('div');
86117
document.body.appendChild(target);
@@ -91,7 +122,7 @@ describe('HoverService', () => {
91122
});
92123

93124
it('renders the hover in a secondary window document if the target lives there', async () => {
94-
const secondaryDocument = document.implementation.createHTMLDocument('secondary window');
125+
const { secondaryDocument } = createSecondaryWindowDocument();
95126
const target = secondaryDocument.createElement('div');
96127
secondaryDocument.body.appendChild(target);
97128
hoverService.requestHover({ content: 'secondary window hover', target, position: 'right', skipHoverDelay: true });
@@ -101,6 +132,136 @@ describe('HoverService', () => {
101132
target.remove();
102133
});
103134

135+
it('creates the hover host in the document of the target instead of adopting it across documents', async () => {
136+
const mainTarget = document.createElement('div');
137+
document.body.appendChild(mainTarget);
138+
hoverService.requestHover({ content: 'main', target: mainTarget, position: 'right', skipHoverDelay: true });
139+
await waitForHover();
140+
const mainHost = document.querySelector('.theia-hover');
141+
expect(mainHost, 'hover should be in the main document').to.exist;
142+
143+
const { secondaryDocument } = createSecondaryWindowDocument();
144+
const secondaryTarget = secondaryDocument.createElement('div');
145+
secondaryDocument.body.appendChild(secondaryTarget);
146+
hoverService.requestHover({ content: 'secondary', target: secondaryTarget, position: 'right', skipHoverDelay: true });
147+
await waitForHover();
148+
const secondaryHost = secondaryDocument.querySelector('.theia-hover');
149+
expect(secondaryHost, 'hover should be in the secondary document').to.exist;
150+
// moving a host into another document would make it outlive its window; a host must be
151+
// created in the document it is shown in
152+
expect(secondaryHost, 'the secondary host must not be the adopted main host').to.not.equal(mainHost);
153+
expect(secondaryHost!.ownerDocument).to.equal(secondaryDocument);
154+
mainTarget.remove();
155+
secondaryTarget.remove();
156+
});
157+
158+
it('cancels the hover when the window hosting it is closed', async () => {
159+
const { secondaryDocument, fireEvent } = createSecondaryWindowDocument();
160+
const target = secondaryDocument.createElement('div');
161+
secondaryDocument.body.appendChild(target);
162+
hoverService.requestHover({ content: 'secondary window hover', target, position: 'right', skipHoverDelay: true });
163+
await waitForHover();
164+
expect(secondaryDocument.querySelector('.theia-hover'), 'hover should be in the secondary document').to.exist;
165+
166+
fireEvent('pagehide');
167+
expect(secondaryDocument.querySelector('.theia-hover'), 'hover should be removed when its window closes').to.not.exist;
168+
169+
// hovers in the main window must keep working afterwards
170+
const mainTarget = document.createElement('div');
171+
document.body.appendChild(mainTarget);
172+
hoverService.requestHover({ content: 'after window close', target: mainTarget, position: 'right', skipHoverDelay: true });
173+
await waitForHover();
174+
expect(document.querySelector('.theia-hover'), 'hover should be rendered in the main document afterwards').to.exist;
175+
target.remove();
176+
mainTarget.remove();
177+
});
178+
179+
it('does not render a hover for a target in an already closed window', async () => {
180+
const { secondaryDocument } = createSecondaryWindowDocument({ closed: true });
181+
const target = secondaryDocument.createElement('div');
182+
secondaryDocument.body.appendChild(target);
183+
hoverService.requestHover({ content: 'closed window hover', target, position: 'right', skipHoverDelay: true });
184+
await waitForHover();
185+
expect(secondaryDocument.querySelector('.theia-hover'), 'no hover should be rendered in a closed window').to.not.exist;
186+
expect(document.querySelector('.theia-hover'), 'no hover should be rendered in the main document either').to.not.exist;
187+
target.remove();
188+
});
189+
190+
it('keeps the hover host hidden until it has been positioned', async () => {
191+
// the host is appended (and the popover shown) at (0, 0) first and only positioned after an
192+
// animation frame; it must not be hittable in the meantime: a visible popover at (0, 0) can
193+
// cover the target, kick it out of the hover chain and retrigger mouseenter hovers in an
194+
// endless show/hide loop (e.g. for tabs at the top-left corner of a secondary window)
195+
const target = document.createElement('div');
196+
document.body.appendChild(target);
197+
const rendering = (hoverService as any).renderHover({ content: 'positioning', target, position: 'right' }) as Promise<void>;
198+
const host = document.querySelector('.theia-hover') as HTMLElement;
199+
expect(host, 'hover should be appended synchronously').to.exist;
200+
expect(host.style.visibility, 'hover must not be visible before it has been positioned').to.equal('hidden');
201+
await rendering;
202+
expect(host.style.visibility, 'hover should be visible once positioned').to.equal('visible');
203+
target.remove();
204+
});
205+
206+
it('does not reveal a hover that was superseded while waiting to be positioned', async () => {
207+
const target = document.createElement('div');
208+
document.body.appendChild(target);
209+
const service = hoverService as any;
210+
const first = service.renderHover({ content: 'first', target, position: 'right' }) as Promise<void>;
211+
const second = service.renderHover({ content: 'second', target, position: 'right' }) as Promise<void>;
212+
await first;
213+
const host = document.querySelector('.theia-hover') as HTMLElement;
214+
expect(host.style.visibility, 'the superseded render must not reveal the host').to.equal('hidden');
215+
await second;
216+
expect(host.style.visibility, 'the latest render reveals the host').to.equal('visible');
217+
target.remove();
218+
});
219+
220+
it('does not leak css classes from a hover that was superseded while waiting to be positioned', async () => {
221+
const service = hoverService as any;
222+
// keep the first render stuck waiting for its animation frame so that a second hover supersedes it mid-render
223+
const originalAnimationFrame = service.hostAnimationFrame.bind(service);
224+
let releaseFirst: () => void;
225+
let animationFrameCalls = 0;
226+
service.hostAnimationFrame = (element: HTMLElement) => ++animationFrameCalls === 1
227+
? new Promise<void>(resolve => { releaseFirst = resolve; })
228+
: originalAnimationFrame(element);
229+
const target = document.createElement('div');
230+
document.body.appendChild(target);
231+
hoverService.requestHover({ content: 'first', target, position: 'right', skipHoverDelay: true, cssClasses: ['first-hover-class'] });
232+
await waitForHover();
233+
hoverService.requestHover({ content: 'second', target, position: 'right', skipHoverDelay: true });
234+
await waitForHover();
235+
releaseFirst!(); // let the superseded render finish
236+
await waitForHover();
237+
const host = document.querySelector('.theia-hover');
238+
expect(host, 'second hover should be rendered').to.exist;
239+
expect(host!.classList.contains('first-hover-class'), 'the superseded hover must not leak its css classes').to.equal(false);
240+
target.remove();
241+
});
242+
243+
it('recovers if the open hover can no longer be hidden', async () => {
244+
// simulate a hover whose document is no longer fully active, e.g. because the secondary
245+
// window hosting it was closed: hidePopover throws and must not break subsequent hovers
246+
const target = document.createElement('div');
247+
document.body.appendChild(target);
248+
hoverService.requestHover({ content: 'first', target, position: 'right', skipHoverDelay: true });
249+
await waitForHover();
250+
const host = document.querySelector('.theia-hover') as HTMLElement;
251+
expect(host, 'first hover should be rendered').to.exist;
252+
(host as any).hidePopover = () => { throw new Error('InvalidStateError: not fully active'); };
253+
254+
const secondTarget = document.createElement('div');
255+
document.body.appendChild(secondTarget);
256+
hoverService.requestHover({ content: 'second', target: secondTarget, position: 'right', skipHoverDelay: true });
257+
await waitForHover();
258+
const secondHost = document.querySelector('.theia-hover');
259+
expect(secondHost, 'hover should be rendered again in the main document').to.exist;
260+
expect(secondHost!.textContent).to.equal('second');
261+
target.remove();
262+
secondTarget.remove();
263+
});
264+
104265
describe('position fallback', () => {
105266
// simulated window: 400px wide, 600px high
106267
const windowWidth = 400;
@@ -115,7 +276,6 @@ describe('HoverService', () => {
115276
beforeEach(() => {
116277
target = document.createElement('div');
117278
document.body.appendChild(target);
118-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
119279
const host: HTMLElement = (hoverService as any).hoverHost;
120280
host.getBoundingClientRect = () => rect(0, 0, 300, 50);
121281
originalBodyRect = document.body.getBoundingClientRect.bind(document.body);
@@ -125,13 +285,11 @@ describe('HoverService', () => {
125285

126286
afterEach(() => {
127287
document.body.getBoundingClientRect = originalBodyRect;
128-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
129288
delete (document.documentElement as any).scrollHeight;
130289
target.remove();
131290
});
132291

133292
function setHostPosition(position: 'left' | 'right' | 'top' | 'bottom'): string {
134-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
135293
const service = hoverService as any;
136294
return service.setHostPosition(target, service.hoverHost, position);
137295
}
@@ -152,28 +310,10 @@ describe('HoverService', () => {
152310
});
153311

154312
it('keeps the requested direction when the perpendicular direction does not fit either', () => {
155-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
156313
const host: HTMLElement = (hoverService as any).hoverHost;
157314
host.getBoundingClientRect = () => rect(0, 0, 300, windowHeight); // hover as tall as the window
158315
target.getBoundingClientRect = () => rect(0, 100, windowWidth, 20);
159316
expect(setHostPosition('left')).to.equal('right');
160317
});
161318
});
162-
163-
it('recovers if the document hosting an open hover is no longer active', async () => {
164-
// simulate a hover host left popover-open in a closed secondary window's document:
165-
// hidePopover then throws 'InvalidStateError' and must not break subsequent hovers
166-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
167-
const host: HTMLElement = (hoverService as any).hoverHost;
168-
host.matches = (selectors: string) => selectors === ':popover-open';
169-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
170-
(host as any).hidePopover = () => { throw new Error('InvalidStateError: not fully active'); };
171-
const target = document.createElement('div');
172-
document.body.appendChild(target);
173-
hoverService.requestHover({ content: 'after window close', target, position: 'right', skipHoverDelay: true });
174-
stubPopoverApi(hoverService); // restore working popover stubs for the new hover
175-
await waitForHover();
176-
expect(document.querySelector('.theia-hover'), 'hover should be rendered again in the main document').to.exist;
177-
target.remove();
178-
});
179319
});

0 commit comments

Comments
 (0)