From b0e0e71974d93b16df9f06fd5daf37e8470219f8 Mon Sep 17 00:00:00 2001 From: Cesar Gonzalez Date: Mon, 16 Sep 2024 08:35:56 -0500 Subject: [PATCH] [PM-11517] Improve autofill collection of page details performance (#10816) * Testing out a rework of the performance improvements introduced into extension * Working through improvements * Implementing max_depth methodology for the deepQuery approach used when querying elements * Refactoring implementation * Refactoring implementation * Fixing jest tests * Incorporating documenation within domQueryService * [PM-11519] `browser` global reference triggering an error when sending an extension message * [PM-11517] Working through refactoring and jest testing of the domQueryService * [PM-11517] Working through refactoring and jest testing of the domQueryService * [PM-11517] Incorporating tests for the debounce util method * [PM-11517] Incorporating tests for the debounce util method * [PM-11517] Removing unnecessary property * [PM-11517] Starting to work through an idea regarding querying without the shadowDom on pages that definitively do not contain a ShadowDOM element * [PM-11419] Adjusting implementation to ensure we clear any active requests when the passkeys setting is modified * [PM-11517] Removing unnecessary comments --- .../content/auto-submit-login.spec.ts | 19 +- .../src/autofill/content/auto-submit-login.ts | 39 ++- .../src/autofill/content/autofill-init.ts | 2 +- .../abstractions/dom-query.service.ts | 11 +- .../autofill-overlay-content.service.ts | 24 +- .../collect-autofill-content.service.spec.ts | 58 ++-- .../collect-autofill-content.service.ts | 281 +++++++----------- .../services/dom-query.service.spec.ts | 86 +++++- .../autofill/services/dom-query.service.ts | 132 ++++++-- .../insert-autofill-content.service.spec.ts | 2 +- .../src/autofill/spec/testing-utils.ts | 2 +- apps/browser/src/autofill/utils/index.spec.ts | 33 ++ apps/browser/src/autofill/utils/index.ts | 26 ++ libs/common/src/autofill/constants/index.ts | 2 + 14 files changed, 437 insertions(+), 280 deletions(-) diff --git a/apps/browser/src/autofill/content/auto-submit-login.spec.ts b/apps/browser/src/autofill/content/auto-submit-login.spec.ts index 98caee3d36..ff1dbd4e94 100644 --- a/apps/browser/src/autofill/content/auto-submit-login.spec.ts +++ b/apps/browser/src/autofill/content/auto-submit-login.spec.ts @@ -5,23 +5,17 @@ import { createAutofillPageDetailsMock, createAutofillScriptMock, } from "../spec/autofill-mocks"; -import { flushPromises, sendMockExtensionMessage } from "../spec/testing-utils"; +import { + flushPromises, + mockQuerySelectorAllDefinedCall, + sendMockExtensionMessage, +} from "../spec/testing-utils"; import { FormFieldElement } from "../types"; let pageDetailsMock: AutofillPageDetails; let fillScriptMock: AutofillScript; let autofillFieldElementByOpidMock: FormFieldElement; -jest.mock("../services/dom-query.service", () => { - const module = jest.requireActual("../services/dom-query.service"); - return { - DomQueryService: class extends module.DomQueryService { - deepQueryElements(element: HTMLElement, queryString: string): T[] { - return Array.from(element.querySelectorAll(queryString)) as T[]; - } - }, - }; -}); jest.mock("../services/collect-autofill-content.service", () => { const module = jest.requireActual("../services/collect-autofill-content.service"); return { @@ -47,6 +41,8 @@ jest.mock("../services/collect-autofill-content.service", () => { jest.mock("../services/insert-autofill-content.service"); describe("AutoSubmitLogin content script", () => { + const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall(); + beforeEach(() => { jest.useFakeTimers(); setupEnvironmentDefaults(); @@ -60,6 +56,7 @@ describe("AutoSubmitLogin content script", () => { afterAll(() => { jest.clearAllMocks(); + mockQuerySelectorAll.mockRestore(); }); it("ends the auto-submit login workflow if the page does not contain any fields", async () => { diff --git a/apps/browser/src/autofill/content/auto-submit-login.ts b/apps/browser/src/autofill/content/auto-submit-login.ts index ab7f09f804..e304247a66 100644 --- a/apps/browser/src/autofill/content/auto-submit-login.ts +++ b/apps/browser/src/autofill/content/auto-submit-login.ts @@ -10,7 +10,9 @@ import InsertAutofillContentService from "../services/insert-autofill-content.se import { elementIsInputElement, getSubmitButtonKeywordsSet, + nodeIsButtonElement, nodeIsFormElement, + nodeIsTypeSubmitElement, sendExtensionMessage, } from "../utils"; @@ -189,13 +191,21 @@ import { element: HTMLElement, lastFieldIsPasswordInput = false, ): boolean { - const genericSubmitElement = querySubmitButtonElement(element, "[type='submit']"); + const genericSubmitElement = querySubmitButtonElement( + element, + "[type='submit']", + (node: Node) => nodeIsTypeSubmitElement(node), + ); if (genericSubmitElement) { clickSubmitElement(genericSubmitElement, lastFieldIsPasswordInput); return true; } - const buttonElement = querySubmitButtonElement(element, "button, [type='button']"); + const buttonElement = querySubmitButtonElement( + element, + "button, [type='button']", + (node: Node) => nodeIsButtonElement(node), + ); if (buttonElement) { clickSubmitElement(buttonElement, lastFieldIsPasswordInput); return true; @@ -210,11 +220,17 @@ import { * * @param element - The element to query for submit buttons * @param selector - The selector to query for submit buttons + * @param treeWalkerFilter - The callback used to filter treeWalker results */ - function querySubmitButtonElement(element: HTMLElement, selector: string) { - const submitButtonElements = domQueryService.deepQueryElements( + function querySubmitButtonElement( + element: HTMLElement, + selector: string, + treeWalkerFilter: CallableFunction, + ) { + const submitButtonElements = domQueryService.query( element, selector, + treeWalkerFilter, ); for (let index = 0; index < submitButtonElements.length; index++) { const submitElement = submitButtonElements[index]; @@ -272,20 +288,11 @@ import { * Gets all form elements on the page. */ function getAutofillFormElements(): HTMLFormElement[] { - const formElements: HTMLFormElement[] = []; - domQueryService.queryAllTreeWalkerNodes( + return domQueryService.query( globalContext.document.documentElement, - (node: Node) => { - if (nodeIsFormElement(node)) { - formElements.push(node); - return true; - } - - return false; - }, + "form", + (node: Node) => nodeIsFormElement(node), ); - - return formElements; } /** diff --git a/apps/browser/src/autofill/content/autofill-init.ts b/apps/browser/src/autofill/content/autofill-init.ts index c0cbac3ae6..e901000dbb 100644 --- a/apps/browser/src/autofill/content/autofill-init.ts +++ b/apps/browser/src/autofill/content/autofill-init.ts @@ -38,7 +38,7 @@ class AutofillInit implements AutofillInitInterface { * @param overlayNotificationsContentService - The overlay notifications content service, potentially undefined. */ constructor( - private domQueryService: DomQueryService, + domQueryService: DomQueryService, private autofillOverlayContentService?: AutofillOverlayContentService, private autofillInlineMenuContentService?: AutofillInlineMenuContentService, private overlayNotificationsContentService?: OverlayNotificationsContentService, diff --git a/apps/browser/src/autofill/services/abstractions/dom-query.service.ts b/apps/browser/src/autofill/services/abstractions/dom-query.service.ts index 8b0b2f5dbd..3e0242bc74 100644 --- a/apps/browser/src/autofill/services/abstractions/dom-query.service.ts +++ b/apps/browser/src/autofill/services/abstractions/dom-query.service.ts @@ -1,12 +1,11 @@ export interface DomQueryService { - deepQueryElements( + query( root: Document | ShadowRoot | Element, queryString: string, + treeWalkerFilter: CallableFunction, mutationObserver?: MutationObserver, + forceDeepQueryAttempt?: boolean, ): T[]; - queryAllTreeWalkerNodes( - rootNode: Node, - filterCallback: CallableFunction, - mutationObserver?: MutationObserver, - ): Node[]; + checkPageContainsShadowDom(): void; + pageContainsShadowDomElements(): boolean; } diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 23a4fc2700..2e85fa2281 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -32,6 +32,8 @@ import { elementIsFillableFormField, elementIsSelectElement, getAttributeBoolean, + nodeIsButtonElement, + nodeIsTypeSubmitElement, sendExtensionMessage, throttle, } from "../utils"; @@ -508,12 +510,20 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ * @param element - The element to find the submit button within. */ private findSubmitButton(element: HTMLElement): HTMLElement | null { - const genericSubmitElement = this.querySubmitButtonElement(element, "[type='submit']"); + const genericSubmitElement = this.querySubmitButtonElement( + element, + "[type='submit']", + (node: Node) => nodeIsTypeSubmitElement(node), + ); if (genericSubmitElement) { return genericSubmitElement; } - const submitButtonElement = this.querySubmitButtonElement(element, "button, [type='button']"); + const submitButtonElement = this.querySubmitButtonElement( + element, + "button, [type='button']", + (node: Node) => nodeIsButtonElement(node), + ); if (submitButtonElement) { return submitButtonElement; } @@ -524,11 +534,17 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ * * @param element - The element to query for a submit button. * @param selector - The selector to use to query the element for a submit button. + * @param treeWalkerFilter - The tree walker filter to use when querying the element. */ - private querySubmitButtonElement(element: HTMLElement, selector: string) { - const submitButtonElements = this.domQueryService.deepQueryElements( + private querySubmitButtonElement( + element: HTMLElement, + selector: string, + treeWalkerFilter: CallableFunction, + ) { + const submitButtonElements = this.domQueryService.query( element, selector, + treeWalkerFilter, ); for (let index = 0; index < submitButtonElements.length; index++) { const submitElement = submitButtonElements[index]; diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts index 97b231a1da..441a2ea17a 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts @@ -17,6 +17,14 @@ import { CollectAutofillContentService } from "./collect-autofill-content.servic import DomElementVisibilityService from "./dom-element-visibility.service"; import { DomQueryService } from "./dom-query.service"; +jest.mock("../utils", () => { + const utils = jest.requireActual("../utils"); + return { + ...utils, + debounce: jest.fn((fn) => fn), + }; +}); + const mockLoginForm = `
@@ -29,6 +37,7 @@ const mockLoginForm = ` const waitForIdleCallback = () => new Promise((resolve) => globalThis.requestIdleCallback(resolve)); describe("CollectAutofillContentService", () => { + const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall(); const domElementVisibilityService = new DomElementVisibilityService(); const inlineMenuFieldQualificationService = mock(); const domQueryService = new DomQueryService(); @@ -38,7 +47,6 @@ describe("CollectAutofillContentService", () => { ); let collectAutofillContentService: CollectAutofillContentService; const mockIntersectionObserver = mock(); - const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall(); beforeEach(() => { globalThis.requestIdleCallback = jest.fn((cb, options) => setTimeout(cb, 100)); @@ -55,6 +63,7 @@ describe("CollectAutofillContentService", () => { afterEach(() => { jest.clearAllMocks(); jest.restoreAllMocks(); + jest.clearAllTimers(); document.body.innerHTML = ""; }); @@ -2001,41 +2010,6 @@ describe("CollectAutofillContentService", () => { }); }); - describe("getShadowRoot", () => { - beforeEach(() => { - // eslint-disable-next-line - // @ts-ignore - globalThis.chrome.dom = { - openOrClosedShadowRoot: jest.fn(), - }; - }); - - it("returns null if the passed node is not an HTMLElement instance", () => { - const textNode = document.createTextNode("Hello, world!"); - const shadowRoot = collectAutofillContentService["getShadowRoot"](textNode); - - expect(shadowRoot).toEqual(null); - }); - - it("returns an open shadow root if the passed node has a shadowDOM element", () => { - const element = document.createElement("div"); - element.attachShadow({ mode: "open" }); - - const shadowRoot = collectAutofillContentService["getShadowRoot"](element); - - expect(shadowRoot).toBeInstanceOf(ShadowRoot); - }); - - it("returns a value provided by Chrome's openOrClosedShadowRoot API", () => { - const element = document.createElement("div"); - collectAutofillContentService["getShadowRoot"](element); - - // eslint-disable-next-line - // @ts-ignore - expect(chrome.dom.openOrClosedShadowRoot).toBeCalled(); - }); - }); - describe("setupMutationObserver", () => { it("sets up a mutation observer and observes the document element", () => { jest.spyOn(MutationObserver.prototype, "observe"); @@ -2048,6 +2022,12 @@ describe("CollectAutofillContentService", () => { }); describe("handleMutationObserverMutation", () => { + const waitForAllMutationsToComplete = async () => { + await waitForIdleCallback(); + await waitForIdleCallback(); + await waitForIdleCallback(); + }; + it("will set the domRecentlyMutated value to true and the noFieldsFound value to false if a form or field node has been added ", async () => { const form = document.createElement("form"); document.body.appendChild(form); @@ -2071,7 +2051,7 @@ describe("CollectAutofillContentService", () => { jest.spyOn(collectAutofillContentService as any, "isAutofillElementNodeMutated"); collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]); - await waitForIdleCallback(); + await waitForAllMutationsToComplete(); expect(collectAutofillContentService["domRecentlyMutated"]).toEqual(true); expect(collectAutofillContentService["noFieldsFound"]).toEqual(false); @@ -2115,7 +2095,7 @@ describe("CollectAutofillContentService", () => { target: document.body, }, ]); - await waitForIdleCallback(); + await waitForAllMutationsToComplete(); expect(collectAutofillContentService["_autofillFormElements"].size).toEqual(0); expect(collectAutofillContentService["autofillFieldElements"].size).toEqual(0); @@ -2140,7 +2120,7 @@ describe("CollectAutofillContentService", () => { jest.spyOn(collectAutofillContentService as any, "handleAutofillElementAttributeMutation"); collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]); - await waitForIdleCallback(); + await waitForAllMutationsToComplete(); expect(collectAutofillContentService["domRecentlyMutated"]).toEqual(false); expect(collectAutofillContentService["noFieldsFound"]).toEqual(true); diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index efacafbe88..7da1d6aa6b 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -20,6 +20,7 @@ import { getPropertyOrAttribute, requestIdleCallbackPolyfill, cancelIdleCallbackPolyfill, + debounce, } from "../utils"; import { AutofillOverlayContentService } from "./abstractions/autofill-overlay-content.service"; @@ -57,7 +58,6 @@ export class CollectAutofillContentService implements CollectAutofillContentServ "image", "file", ]); - private useTreeWalkerStrategyFlagSet = true; constructor( private domElementVisibilityService: DomElementVisibilityService, @@ -69,11 +69,6 @@ export class CollectAutofillContentService implements CollectAutofillContentServ inputQuery += `:not([type="${type}"])`; } this.formFieldQueryString = `${inputQuery}, textarea:not([data-bwignore]), select:not([data-bwignore]), span[data-bwautofill]`; - - // void sendExtensionMessage("getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag").then( - // (useTreeWalkerStrategyFlag) => - // (this.useTreeWalkerStrategyFlagSet = !!useTreeWalkerStrategyFlag?.result), - // ); } get autofillFormElements(): AutofillFormElements { @@ -297,13 +292,12 @@ export class CollectAutofillContentService implements CollectAutofillContentServ ): FormFieldElement[] { let formFieldElements = previouslyFoundFormFieldElements; if (!formFieldElements) { - formFieldElements = this.useTreeWalkerStrategyFlagSet - ? this.queryTreeWalkerForAutofillFormFieldElements() - : this.domQueryService.deepQueryElements( - document, - this.formFieldQueryString, - this.mutationObserver, - ); + formFieldElements = this.domQueryService.query( + globalThis.document.documentElement, + this.formFieldQueryString, + (node: Node) => this.isNodeFormFieldElement(node), + this.mutationObserver, + ); } if (!fieldsLimit || formFieldElements.length <= fieldsLimit) { @@ -836,17 +830,32 @@ export class CollectAutofillContentService implements CollectAutofillContentServ formElements: HTMLFormElement[]; formFieldElements: FormFieldElement[]; } { - if (this.useTreeWalkerStrategyFlagSet) { - return this.queryTreeWalkerForAutofillFormAndFieldElements(); - } - - const queriedElements = this.domQueryService.deepQueryElements( - document, - `form, ${this.formFieldQueryString}`, - this.mutationObserver, - ); const formElements: HTMLFormElement[] = []; const formFieldElements: FormFieldElement[] = []; + + const queriedElements = this.domQueryService.query( + globalThis.document.documentElement, + `form, ${this.formFieldQueryString}`, + (node: Node) => { + if (nodeIsFormElement(node)) { + formElements.push(node); + return true; + } + + if (this.isNodeFormFieldElement(node)) { + formFieldElements.push(node as FormFieldElement); + return true; + } + + return false; + }, + this.mutationObserver, + ); + + if (formElements.length || formFieldElements.length) { + return { formElements, formFieldElements }; + } + for (let index = 0; index < queriedElements.length; index++) { const element = queriedElements[index]; if (elementIsFormElement(element)) { @@ -891,34 +900,6 @@ export class CollectAutofillContentService implements CollectAutofillContentServ return this.nonInputFormFieldTags.has(nodeTagName) && !nodeHasBwIgnoreAttribute; } - /** - * Attempts to get the ShadowRoot of the passed node. If support for the - * extension based openOrClosedShadowRoot API is available, it will be used. - * Will return null if the node is not an HTMLElement or if the node has - * child nodes. - * - * @param {Node} node - */ - private getShadowRoot(node: Node): ShadowRoot | null { - if (!nodeIsElement(node)) { - return null; - } - - if (node.shadowRoot) { - return node.shadowRoot; - } - - if ((chrome as any).dom?.openOrClosedShadowRoot) { - try { - return (chrome as any).dom.openOrClosedShadowRoot(node); - } catch (error) { - return null; - } - } - - return (node as any).openOrClosedShadowRoot; - } - /** * Sets up a mutation observer on the body of the document. Observes changes to * DOM elements to ensure we have an updated set of autofill field data. @@ -948,7 +929,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ } if (!this.mutationsQueue.length) { - requestIdleCallbackPolyfill(this.processMutations, { timeout: 500 }); + requestIdleCallbackPolyfill(debounce(this.processMutations, 100), { timeout: 500 }); } this.mutationsQueue.push(mutations); }; @@ -979,41 +960,62 @@ export class CollectAutofillContentService implements CollectAutofillContentServ * within an idle callback to help with performance and prevent excessive updates. */ private processMutations = () => { - for (let queueIndex = 0; queueIndex < this.mutationsQueue.length; queueIndex++) { - this.processMutationRecord(this.mutationsQueue[queueIndex]); + const queueLength = this.mutationsQueue.length; + + if (!this.domQueryService.pageContainsShadowDomElements()) { + this.domQueryService.checkPageContainsShadowDom(); } - if (this.domRecentlyMutated) { - this.updateAutofillElementsAfterMutation(); + for (let queueIndex = 0; queueIndex < queueLength; queueIndex++) { + const mutations = this.mutationsQueue[queueIndex]; + const processMutationRecords = () => { + this.processMutationRecords(mutations); + + if (queueIndex === queueLength - 1 && this.domRecentlyMutated) { + this.updateAutofillElementsAfterMutation(); + } + }; + + requestIdleCallbackPolyfill(processMutationRecords, { timeout: 500 }); } this.mutationsQueue = []; }; /** - * Processes a mutation record and updates the autofill elements if necessary. + * Processes all mutation records encountered by the mutation observer. * * @param mutations - The mutation record to process */ - private processMutationRecord(mutations: MutationRecord[]) { + private processMutationRecords(mutations: MutationRecord[]) { for (let mutationIndex = 0; mutationIndex < mutations.length; mutationIndex++) { - const mutation = mutations[mutationIndex]; - if ( - mutation.type === "childList" && - (this.isAutofillElementNodeMutated(mutation.removedNodes, true) || - this.isAutofillElementNodeMutated(mutation.addedNodes)) - ) { - this.domRecentlyMutated = true; - if (this.autofillOverlayContentService) { - this.autofillOverlayContentService.pageDetailsUpdateRequired = true; - } - this.noFieldsFound = false; - continue; - } + const mutation: MutationRecord = mutations[mutationIndex]; + const processMutationRecord = () => this.processMutationRecord(mutation); + requestIdleCallbackPolyfill(processMutationRecord, { timeout: 500 }); + } + } - if (mutation.type === "attributes") { - this.handleAutofillElementAttributeMutation(mutation); + /** + * Processes a single mutation record and updates the autofill elements if necessary. + * @param mutation + * @private + */ + private processMutationRecord(mutation: MutationRecord) { + if ( + mutation.type === "childList" && + (this.isAutofillElementNodeMutated(mutation.removedNodes, true) || + this.isAutofillElementNodeMutated(mutation.addedNodes)) + ) { + this.domRecentlyMutated = true; + if (this.autofillOverlayContentService) { + this.autofillOverlayContentService.pageDetailsUpdateRequired = true; } + this.noFieldsFound = false; + return; + } + + if (mutation.type === "attributes") { + this.handleAutofillElementAttributeMutation(mutation); } } @@ -1036,20 +1038,19 @@ export class CollectAutofillContentService implements CollectAutofillContentServ continue; } - if ( - !this.useTreeWalkerStrategyFlagSet && - (nodeIsFormElement(node) || this.isNodeFormFieldElement(node)) - ) { + if (nodeIsFormElement(node) || this.isNodeFormFieldElement(node)) { mutatedElements.push(node as HTMLElement); } - const autofillElements = this.useTreeWalkerStrategyFlagSet - ? this.queryTreeWalkerForMutatedElements(node) - : this.domQueryService.deepQueryElements( - node, - `form, ${this.formFieldQueryString}`, - this.mutationObserver, - ); + const autofillElements = this.domQueryService.query( + node, + `form, ${this.formFieldQueryString}`, + (walkerNode: Node) => + nodeIsFormElement(walkerNode) || this.isNodeFormFieldElement(walkerNode), + this.mutationObserver, + true, + ); + if (autofillElements.length) { mutatedElements = mutatedElements.concat(autofillElements); } @@ -1083,19 +1084,20 @@ export class CollectAutofillContentService implements CollectAutofillContentServ private setupOverlayListenersOnMutatedElements(mutatedElements: Node[]) { for (let elementIndex = 0; elementIndex < mutatedElements.length; elementIndex++) { const node = mutatedElements[elementIndex]; - if ( - !this.isNodeFormFieldElement(node) || - this.autofillFieldElements.get(node as ElementWithOpId) - ) { - continue; - } + const buildAutofillFieldItem = () => { + if ( + !this.isNodeFormFieldElement(node) || + this.autofillFieldElements.get(node as ElementWithOpId) + ) { + return; + } - requestIdleCallbackPolyfill( // We are setting this item to a -1 index because we do not know its position in the DOM. // This value should be updated with the next call to collect page details. - () => void this.buildAutofillFieldItem(node as ElementWithOpId, -1), - { timeout: 1000 }, - ); + void this.buildAutofillFieldItem(node as ElementWithOpId, -1); + }; + + requestIdleCallbackPolyfill(buildAutofillFieldItem, { timeout: 1000 }); } } @@ -1367,6 +1369,19 @@ export class CollectAutofillContentService implements CollectAutofillContentServ } } + /** + * Validates whether a password field is within the document. + */ + isPasswordFieldWithinDocument(): boolean { + return ( + this.domQueryService.query( + globalThis.document.documentElement, + `input[type="password"]`, + (node: Node) => nodeIsInputElement(node) && node.type === "password", + )?.length > 0 + ); + } + /** * Destroys the CollectAutofillContentService. Clears all * timeouts and disconnects the mutation observer. @@ -1378,84 +1393,4 @@ export class CollectAutofillContentService implements CollectAutofillContentServ this.mutationObserver?.disconnect(); this.intersectionObserver?.disconnect(); } - - /** - * @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails. - */ - private queryTreeWalkerForAutofillFormAndFieldElements(): { - formElements: HTMLFormElement[]; - formFieldElements: FormFieldElement[]; - } { - const formElements: HTMLFormElement[] = []; - const formFieldElements: FormFieldElement[] = []; - this.domQueryService.queryAllTreeWalkerNodes( - document.documentElement, - (node: Node) => { - if (nodeIsFormElement(node)) { - formElements.push(node); - return true; - } - - if (this.isNodeFormFieldElement(node)) { - formFieldElements.push(node as FormFieldElement); - return true; - } - - return false; - }, - this.mutationObserver, - ); - - return { formElements, formFieldElements }; - } - - /** - * @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails. - */ - private queryTreeWalkerForAutofillFormFieldElements(): FormFieldElement[] { - return this.domQueryService.queryAllTreeWalkerNodes( - document.documentElement, - (node: Node) => this.isNodeFormFieldElement(node), - this.mutationObserver, - ) as FormFieldElement[]; - } - - /** - * @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails. - * - * @param node - The node to query - */ - private queryTreeWalkerForMutatedElements(node: Node): HTMLElement[] { - return this.domQueryService.queryAllTreeWalkerNodes( - node, - (walkerNode: Node) => - nodeIsFormElement(walkerNode) || this.isNodeFormFieldElement(walkerNode), - this.mutationObserver, - ) as HTMLElement[]; - } - - /** - * @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails. - */ - private queryTreeWalkerForPasswordElements(): HTMLElement[] { - return this.domQueryService.queryAllTreeWalkerNodes( - document.documentElement, - (node: Node) => nodeIsInputElement(node) && node.type === "password", - ) as HTMLElement[]; - } - - /** - * This is a temporary method to maintain a fallback strategy for the tree walker API - * - * @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails. - */ - isPasswordFieldWithinDocument(): boolean { - if (this.useTreeWalkerStrategyFlagSet) { - return Boolean(this.queryTreeWalkerForPasswordElements()?.length); - } - - return Boolean( - this.domQueryService.deepQueryElements(document, `input[type="password"]`)?.length, - ); - } } diff --git a/apps/browser/src/autofill/services/dom-query.service.spec.ts b/apps/browser/src/autofill/services/dom-query.service.spec.ts index 22212333fc..8071a464f4 100644 --- a/apps/browser/src/autofill/services/dom-query.service.spec.ts +++ b/apps/browser/src/autofill/services/dom-query.service.spec.ts @@ -1,21 +1,60 @@ -import { mockQuerySelectorAllDefinedCall } from "../spec/testing-utils"; +import { flushPromises, mockQuerySelectorAllDefinedCall } from "../spec/testing-utils"; import { DomQueryService } from "./dom-query.service"; +jest.mock("../utils", () => { + const actualUtils = jest.requireActual("../utils"); + return { + ...actualUtils, + sendExtensionMessage: jest.fn((command, options) => { + if (command === "getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag") { + return Promise.resolve({ result: false }); + } + + return chrome.runtime.sendMessage(Object.assign({ command }, options)); + }), + }; +}); + describe("DomQueryService", () => { + const originalDocumentReadyState = document.readyState; let domQueryService: DomQueryService; let mutationObserver: MutationObserver; const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall(); - beforeEach(() => { - domQueryService = new DomQueryService(); + beforeEach(async () => { mutationObserver = new MutationObserver(() => {}); + domQueryService = new DomQueryService(); + await flushPromises(); + }); + + afterEach(() => { + Object.defineProperty(document, "readyState", { + value: originalDocumentReadyState, + writable: true, + }); }); afterAll(() => { mockQuerySelectorAll.mockRestore(); }); + it("checks the page content for shadow DOM elements after the page has completed loading", async () => { + Object.defineProperty(document, "readyState", { + value: "loading", + writable: true, + }); + jest.spyOn(globalThis, "addEventListener"); + + const domQueryService = new DomQueryService(); + await flushPromises(); + + expect(globalThis.addEventListener).toHaveBeenCalledWith( + "load", + domQueryService["checkPageContainsShadowDom"], + ); + }); + describe("deepQueryElements", () => { it("queries form field elements that are nested within a ShadowDOM", () => { const root = document.createElement("div"); @@ -26,9 +65,10 @@ describe("DomQueryService", () => { form.appendChild(input); shadowRoot.appendChild(form); - const formFieldElements = domQueryService.deepQueryElements( + const formFieldElements = domQueryService.query( shadowRoot, "input", + (element: Element) => element.tagName === "INPUT", mutationObserver, ); @@ -36,6 +76,7 @@ describe("DomQueryService", () => { }); it("queries form field elements that are nested within multiple ShadowDOM elements", () => { + domQueryService["pageContainsShadowDom"] = true; const root = document.createElement("div"); const shadowRoot1 = root.attachShadow({ mode: "open" }); const root2 = document.createElement("div"); @@ -47,18 +88,50 @@ describe("DomQueryService", () => { shadowRoot2.appendChild(form); shadowRoot1.appendChild(root2); - const formFieldElements = domQueryService.deepQueryElements( + const formFieldElements = domQueryService.query( shadowRoot1, "input", + (element: Element) => element.tagName === "INPUT", mutationObserver, ); expect(formFieldElements).toStrictEqual([input]); }); + + it("will fallback to using the TreeWalker API if a depth larger than 4 ShadowDOM elements is encountered", () => { + domQueryService["pageContainsShadowDom"] = true; + const root = document.createElement("div"); + const shadowRoot1 = root.attachShadow({ mode: "open" }); + const root2 = document.createElement("div"); + const shadowRoot2 = root2.attachShadow({ mode: "open" }); + const root3 = document.createElement("div"); + const shadowRoot3 = root3.attachShadow({ mode: "open" }); + const root4 = document.createElement("div"); + const shadowRoot4 = root4.attachShadow({ mode: "open" }); + const root5 = document.createElement("div"); + const shadowRoot5 = root5.attachShadow({ mode: "open" }); + const form = document.createElement("form"); + const input = document.createElement("input"); + input.type = "text"; + form.appendChild(input); + shadowRoot5.appendChild(form); + shadowRoot4.appendChild(root5); + shadowRoot3.appendChild(root4); + shadowRoot2.appendChild(root3); + shadowRoot1.appendChild(root2); + const treeWalkerCallback = jest + .fn() + .mockImplementation(() => (element: Element) => element.tagName === "INPUT"); + + domQueryService.query(shadowRoot1, "input", treeWalkerCallback, mutationObserver); + + expect(treeWalkerCallback).toHaveBeenCalled(); + }); }); describe("queryAllTreeWalkerNodes", () => { it("queries form field elements that are nested within multiple ShadowDOM elements", () => { + domQueryService["pageContainsShadowDom"] = true; const root = document.createElement("div"); const shadowRoot1 = root.attachShadow({ mode: "open" }); const root2 = document.createElement("div"); @@ -70,8 +143,9 @@ describe("DomQueryService", () => { shadowRoot2.appendChild(form); shadowRoot1.appendChild(root2); - const formFieldElements = domQueryService.queryAllTreeWalkerNodes( + const formFieldElements = domQueryService.query( shadowRoot1, + "input", (element: Element) => element.tagName === "INPUT", mutationObserver, ); diff --git a/apps/browser/src/autofill/services/dom-query.service.ts b/apps/browser/src/autofill/services/dom-query.service.ts index 0d766ea3ba..570027b2d1 100644 --- a/apps/browser/src/autofill/services/dom-query.service.ts +++ b/apps/browser/src/autofill/services/dom-query.service.ts @@ -1,8 +1,77 @@ -import { nodeIsElement } from "../utils"; +import { EVENTS, MAX_DEEP_QUERY_RECURSION_DEPTH } from "@bitwarden/common/autofill/constants"; + +import { nodeIsElement, sendExtensionMessage } from "../utils"; import { DomQueryService as DomQueryServiceInterface } from "./abstractions/dom-query.service"; export class DomQueryService implements DomQueryServiceInterface { + private pageContainsShadowDom: boolean; + private useTreeWalkerStrategyFlagSet = true; + + constructor() { + void this.init(); + } + + /** + * Sets up a query that will trigger a deepQuery of the DOM, querying all elements that match the given query string. + * If the deepQuery fails or reaches a max recursion depth, it will fall back to a treeWalker query. + * + * @param root - The root element to start the query from + * @param queryString - The query string to match elements against + * @param treeWalkerFilter - The filter callback to use for the treeWalker query + * @param mutationObserver - The MutationObserver to use for observing shadow roots + * @param forceDeepQueryAttempt - Whether to force a deep query attempt + */ + query( + root: Document | ShadowRoot | Element, + queryString: string, + treeWalkerFilter: CallableFunction, + mutationObserver?: MutationObserver, + forceDeepQueryAttempt?: boolean, + ): T[] { + if (!forceDeepQueryAttempt && this.pageContainsShadowDomElements()) { + return this.queryAllTreeWalkerNodes(root, treeWalkerFilter, mutationObserver); + } + + try { + return this.deepQueryElements(root, queryString, mutationObserver); + } catch { + return this.queryAllTreeWalkerNodes(root, treeWalkerFilter, mutationObserver); + } + } + + /** + * Checks if the page contains any shadow DOM elements. + */ + checkPageContainsShadowDom = (): void => { + this.pageContainsShadowDom = this.queryShadowRoots(globalThis.document.body, true).length > 0; + }; + + /** + * Determines whether to use the treeWalker strategy for querying the DOM. + */ + pageContainsShadowDomElements(): boolean { + return this.useTreeWalkerStrategyFlagSet || this.pageContainsShadowDom; + } + + /** + * Initializes the DomQueryService, checking for the presence of shadow DOM elements on the page. + */ + private async init() { + const useTreeWalkerStrategyFlag = await sendExtensionMessage( + "getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag", + ); + if (useTreeWalkerStrategyFlag && typeof useTreeWalkerStrategyFlag.result === "boolean") { + this.useTreeWalkerStrategyFlagSet = useTreeWalkerStrategyFlag.result; + } + + if (globalThis.document.readyState === "complete") { + this.checkPageContainsShadowDom(); + return; + } + globalThis.addEventListener(EVENTS.LOAD, this.checkPageContainsShadowDom); + } + /** * Queries all elements in the DOM that match the given query string. * Also, recursively queries all shadow roots for the element. @@ -11,16 +80,25 @@ export class DomQueryService implements DomQueryServiceInterface { * @param queryString - The query string to match elements against * @param mutationObserver - The MutationObserver to use for observing shadow roots */ - deepQueryElements( + private deepQueryElements( root: Document | ShadowRoot | Element, queryString: string, mutationObserver?: MutationObserver, ): T[] { let elements = this.queryElements(root, queryString); - const shadowRoots = this.recursivelyQueryShadowRoots(root, mutationObserver); + + const shadowRoots = this.recursivelyQueryShadowRoots(root); for (let index = 0; index < shadowRoots.length; index++) { const shadowRoot = shadowRoots[index]; elements = elements.concat(this.queryElements(shadowRoot, queryString)); + + if (mutationObserver) { + mutationObserver.observe(shadowRoot, { + attributes: true, + childList: true, + subtree: true, + }); + } } return elements; @@ -46,23 +124,24 @@ export class DomQueryService implements DomQueryServiceInterface { * `isObservingShadowRoot` parameter is set to true. * * @param root - The root element to start the query from - * @param mutationObserver - The MutationObserver to use for observing shadow roots + * @param depth - The depth of the recursion */ private recursivelyQueryShadowRoots( root: Document | ShadowRoot | Element, - mutationObserver?: MutationObserver, + depth: number = 0, ): ShadowRoot[] { + if (!this.pageContainsShadowDom) { + return []; + } + + if (depth >= MAX_DEEP_QUERY_RECURSION_DEPTH) { + throw new Error("Max recursion depth reached"); + } + let shadowRoots = this.queryShadowRoots(root); for (let index = 0; index < shadowRoots.length; index++) { const shadowRoot = shadowRoots[index]; - shadowRoots = shadowRoots.concat(this.recursivelyQueryShadowRoots(shadowRoot)); - if (mutationObserver) { - mutationObserver.observe(shadowRoot, { - attributes: true, - childList: true, - subtree: true, - }); - } + shadowRoots = shadowRoots.concat(this.recursivelyQueryShadowRoots(shadowRoot, depth + 1)); } return shadowRoots; @@ -72,14 +151,23 @@ export class DomQueryService implements DomQueryServiceInterface { * Queries any immediate shadow roots found within the given root element. * * @param root - The root element to start the query from + * @param returnSingleShadowRoot - Whether to return a single shadow root or an array of shadow roots */ - private queryShadowRoots(root: Document | ShadowRoot | Element): ShadowRoot[] { + private queryShadowRoots( + root: Document | ShadowRoot | Element, + returnSingleShadowRoot = false, + ): ShadowRoot[] { const shadowRoots: ShadowRoot[] = []; const potentialShadowRoots = root.querySelectorAll(":defined"); for (let index = 0; index < potentialShadowRoots.length; index++) { const shadowRoot = this.getShadowRoot(potentialShadowRoots[index]); - if (shadowRoot) { - shadowRoots.push(shadowRoot); + if (!shadowRoot) { + continue; + } + + shadowRoots.push(shadowRoot); + if (returnSingleShadowRoot) { + break; } } @@ -121,12 +209,12 @@ export class DomQueryService implements DomQueryServiceInterface { * @param filterCallback * @param mutationObserver */ - queryAllTreeWalkerNodes( + private queryAllTreeWalkerNodes( rootNode: Node, filterCallback: CallableFunction, mutationObserver?: MutationObserver, - ): Node[] { - const treeWalkerQueryResults: Node[] = []; + ): T[] { + const treeWalkerQueryResults: T[] = []; this.buildTreeWalkerNodesQueryResults( rootNode, @@ -147,9 +235,9 @@ export class DomQueryService implements DomQueryServiceInterface { * @param filterCallback * @param mutationObserver */ - private buildTreeWalkerNodesQueryResults( + private buildTreeWalkerNodesQueryResults( rootNode: Node, - treeWalkerQueryResults: Node[], + treeWalkerQueryResults: T[], filterCallback: CallableFunction, mutationObserver?: MutationObserver, ) { @@ -158,7 +246,7 @@ export class DomQueryService implements DomQueryServiceInterface { while (currentNode) { if (filterCallback(currentNode)) { - treeWalkerQueryResults.push(currentNode); + treeWalkerQueryResults.push(currentNode as T); } const nodeShadowRoot = this.getShadowRoot(currentNode); diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts index e5e21c4b02..3541656f4e 100644 --- a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts @@ -68,6 +68,7 @@ function setMockWindowLocation({ } describe("InsertAutofillContentService", () => { + const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall(); const inlineMenuFieldQualificationService = mock(); const domQueryService = new DomQueryService(); const domElementVisibilityService = new DomElementVisibilityService(); @@ -82,7 +83,6 @@ describe("InsertAutofillContentService", () => { ); let insertAutofillContentService: InsertAutofillContentService; let fillScript: AutofillScript; - const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall(); beforeEach(() => { document.body.innerHTML = mockLoginForm; diff --git a/apps/browser/src/autofill/spec/testing-utils.ts b/apps/browser/src/autofill/spec/testing-utils.ts index 4bbcd72dda..a4e7a42eb2 100644 --- a/apps/browser/src/autofill/spec/testing-utils.ts +++ b/apps/browser/src/autofill/spec/testing-utils.ts @@ -176,7 +176,7 @@ export function triggerWebRequestOnCompletedEvent(details: chrome.webRequest.Web export function mockQuerySelectorAllDefinedCall() { const originalDocumentQuerySelectorAll = document.querySelectorAll; - document.querySelectorAll = function (selector: string) { + globalThis.document.querySelectorAll = function (selector: string) { return originalDocumentQuerySelectorAll.call( document, selector === ":defined" ? "*" : selector, diff --git a/apps/browser/src/autofill/utils/index.spec.ts b/apps/browser/src/autofill/utils/index.spec.ts index 36d22ed0cd..62a707860c 100644 --- a/apps/browser/src/autofill/utils/index.spec.ts +++ b/apps/browser/src/autofill/utils/index.spec.ts @@ -10,6 +10,7 @@ import { setElementStyles, setupExtensionDisconnectAction, setupAutofillInitDisconnectAction, + debounce, } from "./index"; describe("buildSvgDomElement", () => { @@ -211,3 +212,35 @@ describe("setupAutofillInitDisconnectAction", () => { expect(window.bitwardenAutofillInit).toBeUndefined(); }); }); + +describe("debounce", () => { + const debouncedFunction = jest.fn(); + const debounced = debounce(debouncedFunction, 100); + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it("does not call the method until the delay is complete", () => { + debounced(); + jest.advanceTimersByTime(50); + expect(debouncedFunction).not.toHaveBeenCalled(); + }); + + it("calls the method a single time when the debounce is triggered multiple times", () => { + debounced(); + debounced(); + debounced(); + jest.advanceTimersByTime(100); + + expect(debouncedFunction).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/browser/src/autofill/utils/index.ts b/apps/browser/src/autofill/utils/index.ts index 7c18e7fd12..98c0a97ac5 100644 --- a/apps/browser/src/autofill/utils/index.ts +++ b/apps/browser/src/autofill/utils/index.ts @@ -311,6 +311,18 @@ export function nodeIsFormElement(node: Node): node is HTMLFormElement { return nodeIsElement(node) && elementIsFormElement(node); } +export function nodeIsTypeSubmitElement(node: Node): node is HTMLElement { + return nodeIsElement(node) && getPropertyOrAttribute(node as HTMLElement, "type") === "submit"; +} + +export function nodeIsButtonElement(node: Node): node is HTMLButtonElement { + return ( + nodeIsElement(node) && + (elementIsInstanceOf(node, "button") || + getPropertyOrAttribute(node as HTMLElement, "type") === "button") + ); +} + /** * Returns a boolean representing the attribute value of an element. * @@ -361,6 +373,20 @@ export function throttle(callback: (_args: any) => any, limit: number) { }; } +/** + * Debounces a callback function to run after a delay of `delay` milliseconds. + * + * @param callback - The callback function to debounce. + * @param delay - The time in milliseconds to debounce the callback. + */ +export function debounce(callback: (_args: any) => any, delay: number) { + let timeout: NodeJS.Timeout; + return function (...args: unknown[]) { + globalThis.clearTimeout(timeout); + timeout = globalThis.setTimeout(() => callback.apply(this, args), delay); + }; +} + /** * Gathers and normalizes keywords from a potential submit button element. Used * to verify if the element submits a login or change password form. diff --git a/libs/common/src/autofill/constants/index.ts b/libs/common/src/autofill/constants/index.ts index 4b0ea53ad0..15005691d2 100644 --- a/libs/common/src/autofill/constants/index.ts +++ b/libs/common/src/autofill/constants/index.ts @@ -107,3 +107,5 @@ export const ExtensionCommand = { export type ExtensionCommandType = (typeof ExtensionCommand)[keyof typeof ExtensionCommand]; export const CLEAR_NOTIFICATION_LOGIN_DATA_DURATION = 60 * 1000; // 1 minute + +export const MAX_DEEP_QUERY_RECURSION_DEPTH = 4;