mirror of
https://github.com/bitwarden/browser.git
synced 2024-12-19 15:57:42 +01:00
[PM-7128] Fix cached form fields not showing the inline menu after their visibility is changed using CSS (#8509)
This commit is contained in:
parent
670f33daa8
commit
77cfa8a5ad
@ -24,6 +24,7 @@ describe("AutofillInit", () => {
|
||||
},
|
||||
});
|
||||
autofillInit = new AutofillInit(autofillOverlayContentService);
|
||||
window.IntersectionObserver = jest.fn(() => mock<IntersectionObserver>());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -173,12 +173,10 @@ describe("AutofillOverlayContentService", () => {
|
||||
autofillFieldData = mock<AutofillField>();
|
||||
});
|
||||
|
||||
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);
|
||||
|
@ -86,7 +86,7 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte
|
||||
formFieldElement: ElementWithOpId<FormFieldElement>,
|
||||
autofillFieldData: AutofillField,
|
||||
) {
|
||||
if (this.isIgnoredField(autofillFieldData)) {
|
||||
if (this.isIgnoredField(autofillFieldData) || this.formFieldElements.has(formFieldElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -27,6 +27,7 @@ describe("CollectAutofillContentService", () => {
|
||||
const domElementVisibilityService = new DomElementVisibilityService();
|
||||
const autofillOverlayContentService = new AutofillOverlayContentService();
|
||||
let collectAutofillContentService: CollectAutofillContentService;
|
||||
const mockIntersectionObserver = mock<IntersectionObserver>();
|
||||
|
||||
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<FormFieldElement>;
|
||||
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<FormFieldElement>;
|
||||
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<FormFieldElement>;
|
||||
const autofillField = mock<AutofillField>();
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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<Element> = 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<FormFieldElement>,
|
||||
@ -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<FormFieldElement>} element
|
||||
* @param {AutofillField} dataTarget
|
||||
* @returns {Promise<void>}
|
||||
* @private
|
||||
*/
|
||||
private async updateAutofillFieldElementData(
|
||||
private updateAutofillFieldElementData(
|
||||
attributeName: string,
|
||||
element: ElementWithOpId<FormFieldElement>,
|
||||
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<FormFieldElement>;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user