[PM-7980] Inline autofill menu is not shown inside dialog html tag (#11474)

* [PM-7980] Fix inline menu not showing inside dialog html tag

* [PM-7980] Fix inline menu not showing inside dialog html tag

* [PM-7980] Fixing an issue where a dialog element could potentially not represent itself in the #top-layer
This commit is contained in:
Cesar Gonzalez 2024-10-15 11:34:14 -05:00 committed by GitHub
parent 55ee33206f
commit 32d12b3d6a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 104 additions and 53 deletions

View File

@ -367,7 +367,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
} }
} }
if (!this.cardAndIdentityCiphers.size) { if (!this.cardAndIdentityCiphers?.size) {
this.cardAndIdentityCiphers = null; this.cardAndIdentityCiphers = null;
} }

View File

@ -14,7 +14,7 @@ describe("AutofillInlineMenuContentService", () => {
let autofillInlineMenuContentService: AutofillInlineMenuContentService; let autofillInlineMenuContentService: AutofillInlineMenuContentService;
let autofillInit: AutofillInit; let autofillInit: AutofillInit;
let sendExtensionMessageSpy: jest.SpyInstance; let sendExtensionMessageSpy: jest.SpyInstance;
let observeBodyMutationsSpy: jest.SpyInstance; let observeContainerMutationsSpy: jest.SpyInstance;
const waitForIdleCallback = () => const waitForIdleCallback = () =>
new Promise((resolve) => globalThis.requestIdleCallback(resolve)); new Promise((resolve) => globalThis.requestIdleCallback(resolve));
@ -25,8 +25,8 @@ describe("AutofillInlineMenuContentService", () => {
autofillInlineMenuContentService = new AutofillInlineMenuContentService(); autofillInlineMenuContentService = new AutofillInlineMenuContentService();
autofillInit = new AutofillInit(domQueryService, null, autofillInlineMenuContentService); autofillInit = new AutofillInit(domQueryService, null, autofillInlineMenuContentService);
autofillInit.init(); autofillInit.init();
observeBodyMutationsSpy = jest.spyOn( observeContainerMutationsSpy = jest.spyOn(
autofillInlineMenuContentService["bodyElementMutationObserver"] as any, autofillInlineMenuContentService["containerElementMutationObserver"] as any,
"observe", "observe",
); );
sendExtensionMessageSpy = jest.spyOn( sendExtensionMessageSpy = jest.spyOn(
@ -51,7 +51,7 @@ describe("AutofillInlineMenuContentService", () => {
describe("extension message handlers", () => { describe("extension message handlers", () => {
describe("closeAutofillInlineMenu message handler", () => { describe("closeAutofillInlineMenu message handler", () => {
beforeEach(() => { beforeEach(() => {
observeBodyMutationsSpy.mockImplementation(); observeContainerMutationsSpy.mockImplementation();
}); });
it("closes the inline menu button", async () => { it("closes the inline menu button", async () => {
@ -87,9 +87,9 @@ describe("AutofillInlineMenuContentService", () => {
}); });
it("closes both inline menu elements and removes the body element mutation observer", async () => { it("closes both inline menu elements and removes the body element mutation observer", async () => {
const unobserveBodyElementSpy = jest.spyOn( const unobserveContainerElementSpy = jest.spyOn(
autofillInlineMenuContentService as any, autofillInlineMenuContentService as any,
"unobserveBodyElement", "unobserveContainerElement",
); );
sendMockExtensionMessage({ sendMockExtensionMessage({
command: "appendAutofillInlineMenuToDom", command: "appendAutofillInlineMenuToDom",
@ -104,7 +104,7 @@ describe("AutofillInlineMenuContentService", () => {
command: "closeAutofillInlineMenu", command: "closeAutofillInlineMenu",
}); });
expect(unobserveBodyElementSpy).toHaveBeenCalled(); expect(unobserveContainerElementSpy).toHaveBeenCalled();
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("autofillOverlayElementClosed", { expect(sendExtensionMessageSpy).toHaveBeenCalledWith("autofillOverlayElementClosed", {
overlayElement: AutofillOverlayElement.Button, overlayElement: AutofillOverlayElement.Button,
}); });
@ -127,7 +127,7 @@ describe("AutofillInlineMenuContentService", () => {
.spyOn(autofillInlineMenuContentService as any, "isInlineMenuListVisible") .spyOn(autofillInlineMenuContentService as any, "isInlineMenuListVisible")
.mockResolvedValue(true); .mockResolvedValue(true);
jest.spyOn(globalThis.document.body, "appendChild"); jest.spyOn(globalThis.document.body, "appendChild");
observeBodyMutationsSpy.mockImplementation(); observeContainerMutationsSpy.mockImplementation();
}); });
describe("creating the inline menu button", () => { describe("creating the inline menu button", () => {
@ -279,7 +279,8 @@ describe("AutofillInlineMenuContentService", () => {
}); });
}); });
describe("handleBodyElementMutationObserverUpdate", () => { describe("handleContainerElementMutationObserverUpdate", () => {
let mockMutationRecord: MockProxy<MutationRecord>;
let buttonElement: HTMLElement; let buttonElement: HTMLElement;
let listElement: HTMLElement; let listElement: HTMLElement;
let isInlineMenuListVisibleSpy: jest.SpyInstance; let isInlineMenuListVisibleSpy: jest.SpyInstance;
@ -289,6 +290,7 @@ describe("AutofillInlineMenuContentService", () => {
<div class="overlay-button"></div> <div class="overlay-button"></div>
<div class="overlay-list"></div> <div class="overlay-list"></div>
`; `;
mockMutationRecord = mock<MutationRecord>({ target: globalThis.document.body } as any);
buttonElement = document.querySelector(".overlay-button") as HTMLElement; buttonElement = document.querySelector(".overlay-button") as HTMLElement;
listElement = document.querySelector(".overlay-list") as HTMLElement; listElement = document.querySelector(".overlay-list") as HTMLElement;
autofillInlineMenuContentService["buttonElement"] = buttonElement; autofillInlineMenuContentService["buttonElement"] = buttonElement;
@ -309,7 +311,9 @@ describe("AutofillInlineMenuContentService", () => {
autofillInlineMenuContentService["buttonElement"] = undefined; autofillInlineMenuContentService["buttonElement"] = undefined;
autofillInlineMenuContentService["listElement"] = undefined; autofillInlineMenuContentService["listElement"] = undefined;
await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"](); autofillInlineMenuContentService["handleContainerElementMutationObserverUpdate"]([
mockMutationRecord,
]);
await waitForIdleCallback(); await waitForIdleCallback();
expect(globalThis.document.body.insertBefore).not.toHaveBeenCalled(); expect(globalThis.document.body.insertBefore).not.toHaveBeenCalled();
@ -323,7 +327,9 @@ describe("AutofillInlineMenuContentService", () => {
) )
.mockReturnValue(true); .mockReturnValue(true);
await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"](); autofillInlineMenuContentService["handleContainerElementMutationObserverUpdate"]([
mockMutationRecord,
]);
await waitForIdleCallback(); await waitForIdleCallback();
expect(globalThis.document.body.insertBefore).not.toHaveBeenCalled(); expect(globalThis.document.body.insertBefore).not.toHaveBeenCalled();
@ -332,14 +338,18 @@ describe("AutofillInlineMenuContentService", () => {
it("skips re-arranging the DOM elements if the last child of the body is non-existent", async () => { it("skips re-arranging the DOM elements if the last child of the body is non-existent", async () => {
document.body.innerHTML = ""; document.body.innerHTML = "";
await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"](); autofillInlineMenuContentService["handleContainerElementMutationObserverUpdate"]([
mockMutationRecord,
]);
await waitForIdleCallback(); await waitForIdleCallback();
expect(globalThis.document.body.insertBefore).not.toHaveBeenCalled(); expect(globalThis.document.body.insertBefore).not.toHaveBeenCalled();
}); });
it("skips re-arranging the DOM elements if the last child of the body is the overlay list and the second to last child of the body is the overlay button", async () => { it("skips re-arranging the DOM elements if the last child of the body is the overlay list and the second to last child of the body is the overlay button", async () => {
await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"](); autofillInlineMenuContentService["handleContainerElementMutationObserverUpdate"]([
mockMutationRecord,
]);
await waitForIdleCallback(); await waitForIdleCallback();
expect(globalThis.document.body.insertBefore).not.toHaveBeenCalled(); expect(globalThis.document.body.insertBefore).not.toHaveBeenCalled();
@ -349,7 +359,9 @@ describe("AutofillInlineMenuContentService", () => {
listElement.remove(); listElement.remove();
isInlineMenuListVisibleSpy.mockResolvedValue(false); isInlineMenuListVisibleSpy.mockResolvedValue(false);
await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"](); autofillInlineMenuContentService["handleContainerElementMutationObserverUpdate"]([
mockMutationRecord,
]);
await waitForIdleCallback(); await waitForIdleCallback();
expect(globalThis.document.body.insertBefore).not.toHaveBeenCalled(); expect(globalThis.document.body.insertBefore).not.toHaveBeenCalled();
@ -359,7 +371,9 @@ describe("AutofillInlineMenuContentService", () => {
const injectedElement = document.createElement("div"); const injectedElement = document.createElement("div");
document.body.insertBefore(injectedElement, listElement); document.body.insertBefore(injectedElement, listElement);
await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"](); autofillInlineMenuContentService["handleContainerElementMutationObserverUpdate"]([
mockMutationRecord,
]);
await waitForIdleCallback(); await waitForIdleCallback();
expect(globalThis.document.body.insertBefore).toHaveBeenCalledWith( expect(globalThis.document.body.insertBefore).toHaveBeenCalledWith(
@ -371,7 +385,9 @@ describe("AutofillInlineMenuContentService", () => {
it("positions the overlay button before the overlay list if the elements have inserted in incorrect order", async () => { it("positions the overlay button before the overlay list if the elements have inserted in incorrect order", async () => {
document.body.appendChild(buttonElement); document.body.appendChild(buttonElement);
await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"](); autofillInlineMenuContentService["handleContainerElementMutationObserverUpdate"]([
mockMutationRecord,
]);
await waitForIdleCallback(); await waitForIdleCallback();
expect(globalThis.document.body.insertBefore).toHaveBeenCalledWith( expect(globalThis.document.body.insertBefore).toHaveBeenCalledWith(
@ -384,7 +400,9 @@ describe("AutofillInlineMenuContentService", () => {
const injectedElement = document.createElement("div"); const injectedElement = document.createElement("div");
document.body.appendChild(injectedElement); document.body.appendChild(injectedElement);
await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"](); autofillInlineMenuContentService["handleContainerElementMutationObserverUpdate"]([
mockMutationRecord,
]);
await waitForIdleCallback(); await waitForIdleCallback();
expect(globalThis.document.body.insertBefore).toHaveBeenCalledWith( expect(globalThis.document.body.insertBefore).toHaveBeenCalledWith(
@ -409,7 +427,9 @@ describe("AutofillInlineMenuContentService", () => {
1000, 1000,
); );
await autofillInlineMenuContentService["handleBodyElementMutationObserverUpdate"](); autofillInlineMenuContentService["handleContainerElementMutationObserverUpdate"]([
mockMutationRecord,
]);
await waitForIdleCallback(); await waitForIdleCallback();
expect(persistentLastChild.style.getPropertyValue("z-index")).toBe("2147483646"); expect(persistentLastChild.style.getPropertyValue("z-index")).toBe("2147483646");

View File

@ -30,7 +30,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
private buttonElement: HTMLElement; private buttonElement: HTMLElement;
private listElement: HTMLElement; private listElement: HTMLElement;
private inlineMenuElementsMutationObserver: MutationObserver; private inlineMenuElementsMutationObserver: MutationObserver;
private bodyElementMutationObserver: MutationObserver; private containerElementMutationObserver: MutationObserver;
private mutationObserverIterations = 0; private mutationObserverIterations = 0;
private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout; private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout;
private handlePersistentLastChildOverrideTimeout: number | NodeJS.Timeout; private handlePersistentLastChildOverrideTimeout: number | NodeJS.Timeout;
@ -102,7 +102,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
return; return;
} }
this.unobserveBodyElement(); this.unobserveContainerElement();
this.closeInlineMenuButton(); this.closeInlineMenuButton();
this.closeInlineMenuList(); this.closeInlineMenuList();
}; };
@ -153,7 +153,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
} }
if (!(await this.isInlineMenuButtonVisible())) { if (!(await this.isInlineMenuButtonVisible())) {
this.appendInlineMenuElementToBody(this.buttonElement); this.appendInlineMenuElementToDom(this.buttonElement);
this.updateInlineMenuElementIsVisibleStatus(AutofillOverlayElement.Button, true); this.updateInlineMenuElementIsVisibleStatus(AutofillOverlayElement.Button, true);
} }
} }
@ -168,7 +168,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
} }
if (!(await this.isInlineMenuListVisible())) { if (!(await this.isInlineMenuListVisible())) {
this.appendInlineMenuElementToBody(this.listElement); this.appendInlineMenuElementToDom(this.listElement);
this.updateInlineMenuElementIsVisibleStatus(AutofillOverlayElement.List, true); this.updateInlineMenuElementIsVisibleStatus(AutofillOverlayElement.List, true);
} }
} }
@ -196,8 +196,15 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
* *
* @param element - The inline menu element to append to the body element. * @param element - The inline menu element to append to the body element.
*/ */
private appendInlineMenuElementToBody(element: HTMLElement) { private appendInlineMenuElementToDom(element: HTMLElement) {
this.observeBodyElement(); const parentDialogElement = globalThis.document.activeElement?.closest("dialog");
if (parentDialogElement && parentDialogElement.open && parentDialogElement.matches(":modal")) {
this.observeContainerElement(parentDialogElement);
parentDialogElement.appendChild(element);
return;
}
this.observeContainerElement(globalThis.document.body);
globalThis.document.body.appendChild(element); globalThis.document.body.appendChild(element);
} }
@ -276,8 +283,8 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
this.handleInlineMenuElementMutationObserverUpdate, this.handleInlineMenuElementMutationObserverUpdate,
); );
this.bodyElementMutationObserver = new MutationObserver( this.containerElementMutationObserver = new MutationObserver(
this.handleBodyElementMutationObserverUpdate, this.handleContainerElementMutationObserverUpdate,
); );
}; };
@ -306,19 +313,17 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
} }
/** /**
* Sets up a mutation observer for the body element. The mutation observer is used * Sets up a mutation observer for the element which contains the inline menu.
* to ensure that the inline menu elements are always present at the bottom of the
* body element.
*/ */
private observeBodyElement() { private observeContainerElement(element: HTMLElement) {
this.bodyElementMutationObserver?.observe(globalThis.document.body, { childList: true }); this.containerElementMutationObserver?.observe(element, { childList: true });
} }
/** /**
* Disconnects the mutation observer for the body element. * Disconnects the mutation observer for the element which contains the inline menu.
*/ */
private unobserveBodyElement() { private unobserveContainerElement() {
this.bodyElementMutationObserver?.disconnect(); this.containerElementMutationObserver?.disconnect();
} }
/** /**
@ -370,11 +375,11 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
} }
/** /**
* Handles the mutation observer update for the body element. This method will * Handles the mutation observer update for the element that contains the inline menu.
* ensure that the inline menu elements are always present at the bottom of the * This method will ensure that the inline menu elements are always present at the
* body element. * bottom of the container.
*/ */
private handleBodyElementMutationObserverUpdate = () => { private handleContainerElementMutationObserverUpdate = (mutations: MutationRecord[]) => {
if ( if (
(!this.buttonElement && !this.listElement) || (!this.buttonElement && !this.listElement) ||
this.isTriggeringExcessiveMutationObserverIterations() this.isTriggeringExcessiveMutationObserverIterations()
@ -382,15 +387,18 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
return; return;
} }
requestIdleCallbackPolyfill(this.processBodyElementMutation, { timeout: 500 }); const containerElement = mutations[0].target as HTMLElement;
requestIdleCallbackPolyfill(() => this.processContainerElementMutation(containerElement), {
timeout: 500,
});
}; };
/** /**
* Processes the mutation of the body element. Will trigger when an * Processes the mutation of the element that contains the inline menu. Will trigger when an
* idle moment in the execution of the main thread is detected. * idle moment in the execution of the main thread is detected.
*/ */
private processBodyElementMutation = async () => { private processContainerElementMutation = async (containerElement: HTMLElement) => {
const lastChild = globalThis.document.body.lastElementChild; const lastChild = containerElement.lastElementChild;
const secondToLastChild = lastChild?.previousElementSibling; const secondToLastChild = lastChild?.previousElementSibling;
const lastChildIsInlineMenuList = lastChild === this.listElement; const lastChildIsInlineMenuList = lastChild === this.listElement;
const lastChildIsInlineMenuButton = lastChild === this.buttonElement; const lastChildIsInlineMenuButton = lastChild === this.buttonElement;
@ -424,11 +432,11 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
(lastChildIsInlineMenuList && !secondToLastChildIsInlineMenuButton) || (lastChildIsInlineMenuList && !secondToLastChildIsInlineMenuButton) ||
(lastChildIsInlineMenuButton && isInlineMenuListVisible) (lastChildIsInlineMenuButton && isInlineMenuListVisible)
) { ) {
globalThis.document.body.insertBefore(this.buttonElement, this.listElement); containerElement.insertBefore(this.buttonElement, this.listElement);
return; return;
} }
globalThis.document.body.insertBefore(lastChild, this.buttonElement); containerElement.insertBefore(lastChild, this.buttonElement);
}; };
/** /**

View File

@ -8,6 +8,7 @@ import {
AutofillOverlayVisibility, AutofillOverlayVisibility,
AUTOFILL_OVERLAY_HANDLE_REPOSITION, AUTOFILL_OVERLAY_HANDLE_REPOSITION,
AUTOFILL_TRIGGER_FORM_FIELD_SUBMIT, AUTOFILL_TRIGGER_FORM_FIELD_SUBMIT,
AUTOFILL_OVERLAY_HANDLE_SCROLL,
} from "@bitwarden/common/autofill/constants"; } from "@bitwarden/common/autofill/constants";
import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
@ -1647,15 +1648,28 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
* the overlay elements on scroll or resize. * the overlay elements on scroll or resize.
*/ */
private setOverlayRepositionEventListeners() { private setOverlayRepositionEventListeners() {
const handler = this.useEventHandlersMemo( const repositionHandler = this.useEventHandlersMemo(
throttle(this.handleOverlayRepositionEvent, 250), throttle(this.handleOverlayRepositionEvent, 250),
AUTOFILL_OVERLAY_HANDLE_REPOSITION, AUTOFILL_OVERLAY_HANDLE_REPOSITION,
); );
globalThis.addEventListener(EVENTS.SCROLL, handler, {
const eventTargetDoesNotContainFocusedField = (element: Element) =>
typeof element?.contains === "function" && !element.contains(this.mostRecentlyFocusedField);
const scrollHandler = this.useEventHandlersMemo(
throttle((event) => {
if (eventTargetDoesNotContainFocusedField(event.target as Element)) {
return;
}
repositionHandler(event);
}, 50),
AUTOFILL_OVERLAY_HANDLE_SCROLL,
);
globalThis.addEventListener(EVENTS.SCROLL, scrollHandler, {
capture: true, capture: true,
passive: true, passive: true,
}); });
globalThis.addEventListener(EVENTS.RESIZE, handler); globalThis.addEventListener(EVENTS.RESIZE, repositionHandler);
} }
/** /**
@ -1663,12 +1677,19 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
* the overlay elements on scroll or resize. * the overlay elements on scroll or resize.
*/ */
private removeOverlayRepositionEventListeners() { private removeOverlayRepositionEventListeners() {
const handler = this.eventHandlersMemo[AUTOFILL_OVERLAY_HANDLE_REPOSITION]; globalThis.removeEventListener(
globalThis.removeEventListener(EVENTS.SCROLL, handler, { EVENTS.SCROLL,
capture: true, this.eventHandlersMemo[AUTOFILL_OVERLAY_HANDLE_SCROLL],
}); {
globalThis.removeEventListener(EVENTS.RESIZE, handler); capture: true,
},
);
globalThis.removeEventListener(
EVENTS.RESIZE,
this.eventHandlersMemo[AUTOFILL_OVERLAY_HANDLE_REPOSITION],
);
delete this.eventHandlersMemo[AUTOFILL_OVERLAY_HANDLE_SCROLL];
delete this.eventHandlersMemo[AUTOFILL_OVERLAY_HANDLE_REPOSITION]; delete this.eventHandlersMemo[AUTOFILL_OVERLAY_HANDLE_REPOSITION];
} }

View File

@ -58,6 +58,8 @@ export const NOTIFICATION_BAR_LIFESPAN_MS = 150000; // 150 seconds
export const AUTOFILL_OVERLAY_HANDLE_REPOSITION = "autofill-overlay-handle-reposition-event"; export const AUTOFILL_OVERLAY_HANDLE_REPOSITION = "autofill-overlay-handle-reposition-event";
export const AUTOFILL_OVERLAY_HANDLE_SCROLL = "autofill-overlay-handle-scroll-event";
export const UPDATE_PASSKEYS_HEADINGS_ON_SCROLL = "update-passkeys-headings-on-scroll"; export const UPDATE_PASSKEYS_HEADINGS_ON_SCROLL = "update-passkeys-headings-on-scroll";
export const AUTOFILL_TRIGGER_FORM_FIELD_SUBMIT = "autofill-trigger-form-field-submit"; export const AUTOFILL_TRIGGER_FORM_FIELD_SUBMIT = "autofill-trigger-form-field-submit";