-
Notifications
You must be signed in to change notification settings - Fork 11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Re-enable mutObs with better safeguards #403
Conversation
Signed-off-by: Emanuele Feliziani <[email protected]>
Signed-off-by: Emanuele Feliziani <[email protected]>
Signed-off-by: Emanuele Feliziani <[email protected]>
Signed-off-by: Emanuele Feliziani <[email protected]>
Signed-off-by: Emanuele Feliziani <[email protected]>
Signed-off-by: Emanuele Feliziani <[email protected]>
Signed-off-by: Emanuele Feliziani <[email protected]>
Signed-off-by: Emanuele Feliziani <[email protected]>
@@ -41,7 +41,7 @@ export async function withEmailProtectionExtensionSignedInAs (page, username) { | |||
* MAX_INPUTS_PER_PAGE: number, | |||
* MAX_FORMS_PER_PAGE: number, | |||
* MAX_INPUTS_PER_FORM: number, | |||
* MAX_FORM_MUT_OBS_COUNT: number | |||
* MAX_FORM_RESCANS: number |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Renamed to capture the fact that the limit is per scan, regardless of whether the mutation observer initiated the rescan. This was at the heart of the issue we were seeing, where the form could rescan the same forms over and over without running into a failsafe.
Signed-off-by: Emanuele Feliziani <[email protected]>
// Ensure we destroy the form if it's removed from the DOM | ||
if (!this.form.isConnected) { | ||
this.destroy() | ||
return | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The mutation observer callback is mostly the same as it was. This little bit is new. Just ensures we're cleaning things up and don't run on disconnected forms. Extremely unlikely, but there you go.
// Must check for inputs because a parent may be removed and not show up in record.removedNodes | ||
if ([...this.inputs.all].some(input => !input.isConnected)) { | ||
// This is re-connected in recategorizeAllInputs, disconnecting here to avoid risk of re-work | ||
this.mutObs.disconnect() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is also new. Since the re-scans happen in an idleCallback, I make sure to disconnect the observer here to avoid any risk of the callback running multiple times in a row. Extreme edge case, but there you go 🙂.
// If the form mutates too much, disconnect to avoid performance issues | ||
if (this.rescanCount >= MAX_FORM_RESCANS) { | ||
this.mutObs.disconnect() | ||
return | ||
} | ||
this.rescanCount++ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the critical part of the fix. In the previous implementation, the counter was only updated within the observer callback, but the recategorizeAllInputs
could also be called from addInput
coming from the scanner. That was bypassing the failsafes and causing it to spin out of control in certain edge cases. Now the count is incremented right within the method so it's never missed 💪🤞.
this.removeAllDecorations() | ||
this.removeTooltip() | ||
this.forgetAllInputs() | ||
this.matching.clear() | ||
this.intObs = null | ||
this.device.scanner.forms.delete(this.form) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is also new. When destroy
ing the form we also ensure not to keep track of it in the scanner ledger. I don't particularly like reaching out to the scanner
, but it's effective.
// This is rather extreme, but better safe than sorry | ||
this.device.scanner.stopScanner('The form has too many inputs, bailing.') | ||
return |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is another crucial part. Previously, the else
block would not return nor stop the scanner, thus a lot of work would keep happening after hitting the failsafes. TBH, stopping the scanner completely is probably overkill, but we've been burned before so I'm happy to go nuclear here.
if (this.form !== document.body) { | ||
this.mutObs.observe(this.form, this.mutObsConfig) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another important part here. The problem happened specifically when autofill got tricked into thinking that the whole body
was a form (for example, when there is an input field at the top of the page and a bunch of fields down below). This ensures that when that happens we don't append the observer at all. Below there are a couple other measures to ensure we decrease the chance of identifying the body
as the form.
console.log('The form has too many inputs, destroying.') | ||
} | ||
this.destroy() | ||
this.device.scanner.stopScanner('The form has too many inputs, destroying.') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Again, going nuclear. Instead of just destroy
ing the form, we stop the scanner altogether.
} | ||
|
||
// When new inputs are added after the initial scan, reanalyze the whole form | ||
if (this.initialScanComplete && this.rescanCount < MAX_FORM_RESCANS) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Checking the counter here as well, to avoid running the FormAnalyzer again, which is very expensive when the form doesn't have good markup.
genericTextField, | ||
'[autocomplete=username]', | ||
'select' | ||
] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I noticed that there was a lot of duplication and incomplete selectors between these two, so I've moved them closer, and derive one from the other to avoid duplication and one getting out of sync with the other. The meaningful change here is to add :not([placeholder^=search i])
which skips more search fields.
<div class="TopbarPageHeaderGlobalActions-settingsMenuAvatar Avatar AvatarPhoto AvatarPhoto--small AvatarPhoto--color6" | ||
role="img" | ||
style="background-image: url("https://s3.amazonaws.com/profile_photos/1144596632862977.1144596633302696.AZv0WLh0YnXpeia8yd53_60x60.png");"></div> | ||
</a></div> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just reformatted while I was using to debug.
let element = input | ||
// traverse the DOM to search for related inputs | ||
while (element.parentElement && element.parentElement !== document.documentElement) { | ||
while (traversalLayerCount <= 5 && element.parentElement && element.parentElement !== document.documentElement) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the last important bit. We limit the upward traversal when searching for a form container. This decreases the chance of false-matching the body
as a form container. For example, in Asana, with a specific sequence of clicks the algo could have matched the whole body
, thus running extremely expensive checks on the whole DOM. With this change the algo stops at the rows of tasks, as expected. This ensures we hit the max form limit much quicker and have more tightly-scoped observers.
thanks for the deep-dive and the inline annotations @GioSensation 🙏🏻 |
Reviewer: @shakyShane
Asana: https://app.asana.com/0/0/1205688498594652/f
Description
Re-enables the mutation observer within the form, with some critical changes highlighted inline. There's a lot more info on what I tried out and what I discarded and why in the task.
Steps to test
Re-enabled the automated tests + followed the steps in the parent task.