diff --git a/apps/browser/src/autofill/content/autofill-init.spec.ts b/apps/browser/src/autofill/content/autofill-init.spec.ts index 8912a8c0ba..b299ddccbf 100644 --- a/apps/browser/src/autofill/content/autofill-init.spec.ts +++ b/apps/browser/src/autofill/content/autofill-init.spec.ts @@ -24,6 +24,7 @@ describe("AutofillInit", () => { }, }); autofillInit = new AutofillInit(autofillOverlayContentService); + window.IntersectionObserver = jest.fn(() => mock()); }); afterEach(() => { diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts index 9f3ffea142..96a1b4c851 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts @@ -173,12 +173,10 @@ describe("AutofillOverlayContentService", () => { autofillFieldData = mock(); }); - it("ignores fields that are readonly", () => { + it("ignores fields that are readonly", async () => { autofillFieldData.readonly = true; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - autofillOverlayContentService.setupAutofillOverlayListenerOnField( + await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, ); @@ -186,12 +184,10 @@ describe("AutofillOverlayContentService", () => { expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled(); }); - it("ignores fields that contain a disabled attribute", () => { + it("ignores fields that contain a disabled attribute", async () => { autofillFieldData.disabled = true; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - autofillOverlayContentService.setupAutofillOverlayListenerOnField( + await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, ); @@ -199,12 +195,10 @@ describe("AutofillOverlayContentService", () => { expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled(); }); - it("ignores fields that are not viewable", () => { + it("ignores fields that are not viewable", async () => { autofillFieldData.viewable = false; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - autofillOverlayContentService.setupAutofillOverlayListenerOnField( + await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, ); @@ -213,12 +207,10 @@ describe("AutofillOverlayContentService", () => { }); it("ignores fields that are part of the ExcludedOverlayTypes", () => { - AutoFillConstants.ExcludedOverlayTypes.forEach((excludedType) => { + AutoFillConstants.ExcludedOverlayTypes.forEach(async (excludedType) => { autofillFieldData.type = excludedType; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - autofillOverlayContentService.setupAutofillOverlayListenerOnField( + await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, ); @@ -227,12 +219,10 @@ describe("AutofillOverlayContentService", () => { }); }); - it("ignores fields that contain the keyword `search`", () => { + it("ignores fields that contain the keyword `search`", async () => { autofillFieldData.placeholder = "search"; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - autofillOverlayContentService.setupAutofillOverlayListenerOnField( + await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, ); @@ -240,12 +230,10 @@ describe("AutofillOverlayContentService", () => { expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled(); }); - it("ignores fields that contain the keyword `captcha` ", () => { + it("ignores fields that contain the keyword `captcha` ", async () => { autofillFieldData.placeholder = "captcha"; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - autofillOverlayContentService.setupAutofillOverlayListenerOnField( + await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, ); @@ -253,12 +241,10 @@ describe("AutofillOverlayContentService", () => { expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled(); }); - it("ignores fields that do not appear as a login field", () => { + it("ignores fields that do not appear as a login field", async () => { autofillFieldData.placeholder = "not-a-login-field"; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - autofillOverlayContentService.setupAutofillOverlayListenerOnField( + await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, ); @@ -267,6 +253,17 @@ describe("AutofillOverlayContentService", () => { }); }); + it("skips setup on fields that have been previously set up", async () => { + autofillOverlayContentService["formFieldElements"].add(autofillFieldElement); + + await autofillOverlayContentService.setupAutofillOverlayListenerOnField( + autofillFieldElement, + autofillFieldData, + ); + + expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled(); + }); + describe("identifies the overlay visibility setting", () => { it("defaults the overlay visibility setting to `OnFieldFocus` if a value is not set", async () => { sendExtensionMessageSpy.mockResolvedValueOnce(undefined); 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 cd373cdfd3..4b786e6ca3 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -86,7 +86,7 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte formFieldElement: ElementWithOpId, autofillFieldData: AutofillField, ) { - if (this.isIgnoredField(autofillFieldData)) { + if (this.isIgnoredField(autofillFieldData) || this.formFieldElements.has(formFieldElement)) { return; } 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 d5c461269b..79cb41b9a1 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 @@ -27,6 +27,7 @@ describe("CollectAutofillContentService", () => { const domElementVisibilityService = new DomElementVisibilityService(); const autofillOverlayContentService = new AutofillOverlayContentService(); let collectAutofillContentService: CollectAutofillContentService; + const mockIntersectionObserver = mock(); beforeEach(() => { document.body.innerHTML = mockLoginForm; @@ -34,6 +35,7 @@ describe("CollectAutofillContentService", () => { domElementVisibilityService, autofillOverlayContentService, ); + window.IntersectionObserver = jest.fn(() => mockIntersectionObserver); }); afterEach(() => { @@ -2527,10 +2529,10 @@ describe("CollectAutofillContentService", () => { }); updatedAttributes.forEach((attribute) => { - it(`will update the ${attribute} value for the field element`, async () => { + it(`will update the ${attribute} value for the field element`, () => { jest.spyOn(collectAutofillContentService["autofillFieldElements"], "set"); - await collectAutofillContentService["updateAutofillFieldElementData"]( + collectAutofillContentService["updateAutofillFieldElementData"]( attribute, fieldElement, autofillField, @@ -2543,10 +2545,10 @@ describe("CollectAutofillContentService", () => { }); }); - it("will not update an attribute value if it is not present in the updateActions object", async () => { + it("will not update an attribute value if it is not present in the updateActions object", () => { jest.spyOn(collectAutofillContentService["autofillFieldElements"], "set"); - await collectAutofillContentService["updateAutofillFieldElementData"]( + collectAutofillContentService["updateAutofillFieldElementData"]( "random-attribute", fieldElement, autofillField, @@ -2555,4 +2557,67 @@ describe("CollectAutofillContentService", () => { expect(collectAutofillContentService["autofillFieldElements"].set).not.toBeCalled(); }); }); + + describe("handleFormElementIntersection", () => { + let isFormFieldViewableSpy: jest.SpyInstance; + let setupAutofillOverlayListenerOnFieldSpy: jest.SpyInstance; + + beforeEach(() => { + isFormFieldViewableSpy = jest.spyOn( + collectAutofillContentService["domElementVisibilityService"], + "isFormFieldViewable", + ); + setupAutofillOverlayListenerOnFieldSpy = jest.spyOn( + collectAutofillContentService["autofillOverlayContentService"], + "setupAutofillOverlayListenerOnField", + ); + }); + + it("skips the initial intersection event for an observed element", async () => { + const formFieldElement = document.createElement("input") as ElementWithOpId; + collectAutofillContentService["elementInitializingIntersectionObserver"].add( + formFieldElement, + ); + const entries = [ + { target: formFieldElement, isIntersecting: true }, + ] as unknown as IntersectionObserverEntry[]; + + await collectAutofillContentService["handleFormElementIntersection"](entries); + + expect(isFormFieldViewableSpy).not.toHaveBeenCalled(); + expect(setupAutofillOverlayListenerOnFieldSpy).not.toHaveBeenCalled(); + }); + + it("skips setting up the overlay listeners on a field that is not viewable", async () => { + const formFieldElement = document.createElement("input") as ElementWithOpId; + const entries = [ + { target: formFieldElement, isIntersecting: true }, + ] as unknown as IntersectionObserverEntry[]; + isFormFieldViewableSpy.mockReturnValueOnce(false); + + await collectAutofillContentService["handleFormElementIntersection"](entries); + + expect(isFormFieldViewableSpy).toHaveBeenCalledWith(formFieldElement); + expect(setupAutofillOverlayListenerOnFieldSpy).not.toHaveBeenCalled(); + }); + + it("sets up the overlay listeners on a viewable field", async () => { + const formFieldElement = document.createElement("input") as ElementWithOpId; + const autofillField = mock(); + const entries = [ + { target: formFieldElement, isIntersecting: true }, + ] as unknown as IntersectionObserverEntry[]; + isFormFieldViewableSpy.mockReturnValueOnce(true); + collectAutofillContentService["autofillFieldElements"].set(formFieldElement, autofillField); + collectAutofillContentService["intersectionObserver"] = mockIntersectionObserver; + + await collectAutofillContentService["handleFormElementIntersection"](entries); + + expect(isFormFieldViewableSpy).toHaveBeenCalledWith(formFieldElement); + expect(setupAutofillOverlayListenerOnFieldSpy).toHaveBeenCalledWith( + formFieldElement, + autofillField, + ); + }); + }); }); 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 3144edcda9..63dee7f3b1 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -38,6 +38,8 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte private autofillFormElements: AutofillFormElements = new Map(); private autofillFieldElements: AutofillFieldElements = new Map(); private currentLocationHref = ""; + private intersectionObserver: IntersectionObserver; + private elementInitializingIntersectionObserver: Set = new Set(); private mutationObserver: MutationObserver; private updateAutofillElementsAfterMutationTimeout: number | NodeJS.Timeout; private readonly updateAfterMutationTimeoutDelay = 1000; @@ -70,6 +72,10 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte this.setupMutationObserver(); } + if (!this.intersectionObserver) { + this.setupIntersectionObserver(); + } + if (!this.domRecentlyMutated && this.noFieldsFound) { return this.getFormattedPageDetails({}, []); } @@ -360,11 +366,14 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte tagName: this.getAttributeLowerCase(element, "tagName"), }; + if (!autofillFieldBase.viewable) { + this.elementInitializingIntersectionObserver.add(element); + this.intersectionObserver.observe(element); + } + if (elementIsSpanElement(element)) { this.cacheAutofillFieldElement(index, element, autofillFieldBase); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.autofillOverlayContentService?.setupAutofillOverlayListenerOnField( + void this.autofillOverlayContentService?.setupAutofillOverlayListenerOnField( element, autofillFieldBase, ); @@ -407,9 +416,10 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte }; this.cacheAutofillFieldElement(index, element, autofillField); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.autofillOverlayContentService?.setupAutofillOverlayListenerOnField(element, autofillField); + void this.autofillOverlayContentService?.setupAutofillOverlayListenerOnField( + element, + autofillField, + ); return autofillField; }; @@ -1189,8 +1199,6 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte return; } - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises this.updateAutofillFieldElementData( attributeName, targetElement as ElementWithOpId, @@ -1232,13 +1240,12 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte /** * Updates the autofill field element data based on the passed attribute name. + * * @param {string} attributeName * @param {ElementWithOpId} element * @param {AutofillField} dataTarget - * @returns {Promise} - * @private */ - private async updateAutofillFieldElementData( + private updateAutofillFieldElementData( attributeName: string, element: ElementWithOpId, dataTarget: AutofillField, @@ -1304,6 +1311,52 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte return attributeValue; } + /** + * Sets up an IntersectionObserver to observe found form + * field elements that are not viewable in the viewport. + */ + private setupIntersectionObserver() { + this.intersectionObserver = new IntersectionObserver(this.handleFormElementIntersection, { + root: null, + rootMargin: "0px", + threshold: 1.0, + }); + } + + /** + * Handles observed form field elements that are not viewable in the viewport. + * Will re-evaluate the visibility of the element and set up the autofill + * overlay listeners on the field if it is viewable. + * + * @param entries - The entries observed by the IntersectionObserver + */ + private handleFormElementIntersection = async (entries: IntersectionObserverEntry[]) => { + for (let entryIndex = 0; entryIndex < entries.length; entryIndex++) { + const entry = entries[entryIndex]; + const formFieldElement = entry.target as ElementWithOpId; + if (this.elementInitializingIntersectionObserver.has(formFieldElement)) { + this.elementInitializingIntersectionObserver.delete(formFieldElement); + continue; + } + + const isViewable = + await this.domElementVisibilityService.isFormFieldViewable(formFieldElement); + if (!isViewable) { + continue; + } + + const cachedAutofillFieldElement = this.autofillFieldElements.get(formFieldElement); + cachedAutofillFieldElement.viewable = true; + + void this.autofillOverlayContentService?.setupAutofillOverlayListenerOnField( + formFieldElement, + cachedAutofillFieldElement, + ); + + this.intersectionObserver.unobserve(entry.target); + } + }; + /** * Destroys the CollectAutofillContentService. Clears all * timeouts and disconnects the mutation observer. @@ -1313,6 +1366,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte clearTimeout(this.updateAutofillElementsAfterMutationTimeout); } this.mutationObserver?.disconnect(); + this.intersectionObserver?.disconnect(); } }