Skip to content
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

Unresponsiveness with 1Password extension #1919

Closed
4 tasks done
victorlin opened this issue Dec 6, 2024 · 19 comments · Fixed by #1932
Closed
4 tasks done

Unresponsiveness with 1Password extension #1919

victorlin opened this issue Dec 6, 2024 · 19 comments · Fixed by #1932
Assignees
Labels
bug Something isn't working

Comments

@victorlin
Copy link
Member

victorlin commented Dec 6, 2024

Recent versions of 1Password browser extension (8.10.56(.28), all browsers) causes Auspice views to be slow and in some cases seemingly unresponsive. The slowness scales with the number of elements rendered by Auspice. This means it is most noticeable for the following scenarios per-panel:

  • Tree: large number of tips
  • Map, Frequency: large number of distinct color-by values
  • Entropy: large genome size

Example for reproducing the issue

  1. Revert Workaround 1Password browser extension bug #1932 and run a local dev server
  2. Ensure the 1Password extension is active
  3. Go to http://localhost:4000/tb/global?d=tree
  4. Scroll down on the sidebar and click the toggle to the right of "Entropy"
  5. Page will become unresponsive

Workarounds

Ordered from least to most work involved. Instructions written for Chrome – may vary for other browsers.

  1. ✅ Apply a workaround in Auspice

  2. Tell affected users to disable the "Offer to save one-time passwords" toggle in 1Password extension settings > Autofill & save or use a different browser/profile without the extension installed.

  3. Tell affected users to downgrade to an older version of 1Password.

    1. Download and unzip 1Password-8.10.52.25_0.zip.
    2. Go to Chrome extension settings and enable Developer mode on the top right.
    3. Click Load unpacked.
    4. Select the unzipped directory.
    5. Check that 1Password extension version is 8.10.52.25.

Progress

Internal links

@victorlin victorlin added the bug Something isn't working label Dec 6, 2024
@victorlin victorlin self-assigned this Dec 6, 2024
@victorlin victorlin changed the title Unresponsiveness with 1Password Chrome extension Unresponsiveness with 1Password extension Dec 10, 2024
@genehack
Copy link
Contributor

I've run into this a couple of times this week already, and it's super annoying — have we had any update from the 1P folks?

@victorlin
Copy link
Member Author

Nope, last I heard was to keep an eye on their releases page. I just checked the latest Beta (8.10.58.31) and Nightly (8.10.58.37) versions on the Chrome Web Store, and the problem still persists. I just sent a follow-up to their support team.

@tsibley
Copy link
Member

tsibley commented Jan 16, 2025

This doesn't affect me personally or I might already be scratching this itch, but has anyone here looked into a) debugging this for them and/or b) developing active countermeasures we can use to disable the extension on Auspice pages? Both should be fully possible.

@victorlin
Copy link
Member Author

a) debugging this for them

My initial debugging on Slack showed that it's injected.js from the extension which is doing unnecessary churn. I shared repro steps and a profiling screenshot with them in the first email, but maybe it's worth bringing up again with more details. I just ran the profile again and this is what it looks like:

Image

The long-running (anonymous) function is this callback to MutationObserver, which specifically is slowed down by calls to zw(r):

  , Uo = class {
    constructor() {
        this.mutationObserver = new MutationObserver(t => {
            for (let n of t) {
                for (let r of n.addedNodes)
                    No(r) && zw(r) && this.intersectionObserver.observe(r);
                for (let r of n.removedNodes)
                    No(r) && (this.intersectionObserver.unobserve(r),
                    this.resizeObserver.unobserve(r))
            }
        }
        ),
        ...

b) developing active countermeasures we can use to disable the extension on Auspice pages

I don't think anyone has looked into this deeply besides the links that @jameshadfield shared initially indicating that it might not be possible. I just found a StackOverflow answer which indicates that it might be possible, but not yet tested.

@tsibley
Copy link
Member

tsibley commented Jan 16, 2025

The long-running (anonymous) function is this callback to MutationObserver, which specifically is slowed down by calls to zw(r):

A couple thoughts from having used MutationObservers that make DOM modifications in an extension before.

  • The MutationObserver callback is DOM-blocking and its processing should probably be moved into a subsequent requestAnimationFrame callback (unless the processing really requires the DOM stay blocked from further modification).

  • What's zw(r) doing inside itself? i.e. what's slow there?

@victorlin
Copy link
Member Author

victorlin commented Jan 16, 2025

Code snippets for each of the functions in the long-running call stack:

Image
(anonymous)
this.mutationObserver = new MutationObserver(t => {
    for (let n of t) {
        for (let r of n.addedNodes)
            No(r) && zw(r) && this.intersectionObserver.observe(r);
        for (let r of n.removedNodes)
            No(r) && (this.intersectionObserver.unobserve(r),
            this.resizeObserver.unobserve(r))
    }
}
),
zw
zw = e => {
    let t = o => o.trim().replace(/[^a-zA-Z0-9]/g, "").toLowerCase()
      , n = MM(e).map(t).filter(o => o !== "");
    return IM.some(o => n.some(i => i.includes(o)))
}
MM
MM = e => {
    let t = e instanceof HTMLImageElement ? [e.alt, e.className, e.id, e.title] : [e.id]
      , n = RM(e);
    return n !== null && t.push(n),
    t
}
RM
var LM = 4;

RM = e => {
    if (e == null)
        return null;
    let t = e.parentNode
      , n = 0;
    for (; n < LM && t !== null; ) {
        if (t instanceof HTMLElement && t.innerText && t.innerText.trim().length > 0)
            return t.innerText;
        t = t.parentNode,
        n++
    }
    return null
}

@victorlin
Copy link
Member Author

1Password support has narrowed the issue down to a specific feature. I've added it as a workaround in the issue description:

Disable the "Offer to save one-time passwords" toggle in 1Password extension settings.

@tsibley
Copy link
Member

tsibley commented Jan 16, 2025

Ha, yeah, I'd also stumbled on that when digging into the source code. The MutationObserver in question is part of a Uo class that's instantiated once and assigned to a autosave2FAManager property elsewhere.

The zw function and functions below it in the stack are enumerating a bunch of attributes, including innerText, of added elements in order to match them against a list of strings that indicate a 2fa element.

  IM = [
    "2fa",
    "2stepverification",
    "authenticationcode",
    "authenticator",
    "authy",
    "barcodelogin",
    "googleauthenticator",
    "loginverification",
    "microsoftauthenticator",
    "multifactor",
    "onetimepassword",
    "qrcode",
    "qrsignin",
    "scancode",
    "scantoconnect",
    "securelogin",
    "securitykey",
    "temporaryaccesscode",
    "timebasedcode",
    "totp",
    "twofactor",
    "verifyidentity",
  ],

The issue is that it doesn't just access the added element's innerText, but also walks the DOM upwards up to 4 levels (LM = 4) and calls innerText for those too.

I suspect putting it into a requestAnimationFrame callback would be helpful to unjank things.

@tsibley
Copy link
Member

tsibley commented Jan 16, 2025

Oh! I think I have a workaround we can apply to Auspice. Let me test.

@tsibley
Copy link
Member

tsibley commented Jan 16, 2025

Ah, ok, I can't test because I can't actually reproduce the issue locally. It appears that the one-time password autosaving feature isn't offered/available (on Linux? or something about my 1Password account?). There's not even a "Offer to save one-time passwords" setting.

But, I think this patch in Auspice would do the trick (for the entropy panel).

From 57b2f63e35ef8fd3dfea055ecaa36727d98701dc Mon Sep 17 00:00:00 2001
From: Thomas Sibley <tsibley@fredhutch.org>
Date: Thu, 16 Jan 2025 13:30:43 -0800
Subject: [PATCH] Workaround 1Password browser extension bug
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The bug pathologically slows down (and sometimes crashes) pages with
large entropy panels, or really anytime thousands/millions of SVG
elements are added to the DOM.¹

Short circuit the extension's calls to the very slow .innerText in its
function:

  RM = (e) => {
    if (e == null) return null;
    let t = e.parentNode,
      n = 0;
    for (; n < 4 /*LM*/ && t !== null; ) {
      if (
        t instanceof HTMLElement &&
        t.innerText &&
        t.innerText.trim().length > 0
      )
        return t.innerText;
      (t = t.parentNode), n++;
    }
    return null;
  },

by nesting our entropy panel's elements deeper than extension's
traversal limit of 4.  That is, we're moving the closest HTMLElement
(the <div> containing the <svg>) further away from the entropy <rect>s.

Just one <g> would do, but adding four guards against future changes to
the internals of the #d3entropy element.

¹ <https://github.com/nextstrain/auspice/issues/1919>
---
 src/components/entropy/index.js | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/src/components/entropy/index.js b/src/components/entropy/index.js
index 947996a8..715db041 100644
--- a/src/components/entropy/index.js
+++ b/src/components/entropy/index.js
@@ -290,7 +290,9 @@ class Entropy extends React.Component {
           width={this.props.width}
           height={this.props.height}
         >
-          <g ref={(c) => { this.d3entropy = c; }} id="d3entropy"/>
+          <g><g><g><g>
+            <g ref={(c) => { this.d3entropy = c; }} id="d3entropy"/>
+          </g></g></g></g>
         </svg>
         {this.resetLayout(styles)}
         {this.entropyCountSwitch(styles)}
-- 
2.47.1

And we could do something similar to other panels (tree, map, frequency) as needed too.

@victorlin
Copy link
Member Author

Clever! I'll test the patch.

@tsibley
Copy link
Member

tsibley commented Jan 16, 2025

It will only work if the bulk of the time is actually innerText. If instead the bulk of the time is merely doing, for each added element, the 4-level parentNode walk and/or string munging of zw's t and/or something else, then it won't solve the issue.

@tsibley
Copy link
Member

tsibley commented Jan 16, 2025

@victorlin Did you (or could you) save the profile in the screenshots above to a file you could share?

@victorlin
Copy link
Member Author

I just switched to 1Password Nightly (version 8.10.58.39) which confirms that the bulk of the time is innerText. Trace-20250116T141055.json, screenshot:

Image

The patch above works well. Trace-20250116T141329.json, screenshot:

Image

victorlin added a commit that referenced this issue Jan 18, 2025
The bug pathologically slows down (and sometimes crashes) pages with
thousands/millions of SVG elements in the DOM, which happens in
reasonably sized datasets.¹

Short circuit the extension's calls to the very slow .innerText in its
function:

  RM = (e) => {
    if (e == null) return null;
    let t = e.parentNode,
      n = 0;
    for (; n < 4 /*LM*/ && t !== null; ) {
      if (
        t instanceof HTMLElement &&
        t.innerText &&
        t.innerText.trim().length > 0
      )
        return t.innerText;
      (t = t.parentNode), n++;
    }
    return null;
  },

by nesting each panel's D3 elements deeper than extension's traversal
limit of 4.  That is, we're moving the closest HTMLElement (e.g. <div>s
containing <svg>s) further away from the leaf SVG elements.

For most panels, fewer <g>s would do, but adding four guards against
future changes to D3 implementation.

The workaround was trivial for entropy and frequencies panels which
already have a separation where size/styles are applied on a parent
<svg> and D3 operations happen on a child <g>. It was still not too bad
for map and measurements panels - those did not have a pre-existing
separation, but adding the separation was trivial.

The tree panel was the least trivial - the code and type annotations
assumed that the root SVG element for D3 operations was an <svg>, so
switching it to <g> required updating those assumptions. Additionally,
there is code that retrieves the width/height from the root SVG element,
so it must be retained on the child element even when nested under an
<svg>, which requires width and height to be set explicitly.

¹ <#1919>

Co-authored-by: Thomas Sibley <tsibley@fredhutch.org>
victorlin added a commit that referenced this issue Jan 21, 2025
The bug pathologically slows down (and sometimes crashes) pages with
thousands/millions of SVG elements in the DOM, which happens in
reasonably sized datasets.¹

Short circuit the extension's calls to the very slow .innerText in its
function:

  RM = (e) => {
    if (e == null) return null;
    let t = e.parentNode,
      n = 0;
    for (; n < 4 /*LM*/ && t !== null; ) {
      if (
        t instanceof HTMLElement &&
        t.innerText &&
        t.innerText.trim().length > 0
      )
        return t.innerText;
      (t = t.parentNode), n++;
    }
    return null;
  },

by nesting each panel's D3 elements deeper than extension's traversal
limit of 4.  That is, we're moving the closest HTMLElement (e.g. <div>s
containing <svg>s) further away from the leaf SVG elements.

For most panels, fewer <g>s would do, but adding four guards against
future changes to D3 implementation.

The workaround was trivial for entropy and frequencies panels which
already have a separation where size/styles are applied on a parent
<svg> and D3 operations happen on a child <g>. It was still not too bad
for map and measurements panels - those did not have a pre-existing
separation, but adding the separation was trivial.

The tree panel was the least trivial - the code and type annotations
assumed that the root SVG element for D3 operations was an <svg>, so
switching it to <g> required updating those assumptions. Additionally,
there is code that retrieves the width/height from the root SVG element,
so it must be retained on the child element even when nested under an
<svg>, which requires width and height to be set explicitly.

¹ <#1919>

Co-authored-by: Thomas Sibley <tsibley@fredhutch.org>
@victorlin
Copy link
Member Author

The workaround has been released in Auspice 2.62.0 which is now used in https://next.nextstrain.org and https://auspice.us. Keeping this issue open so that we can revert the workaround when 1Password releases a proper fix.

@tsibley
Copy link
Member

tsibley commented Jan 22, 2025

Keeping this issue open so that we can revert the workaround when 1Password releases a proper fix.

I wouldn't hold your breath…

I'd probably close this issue—the behaviour is fixed—and keep the conversation with 1Password going in the background on the support ticket. If you really want an issue to track it, then I'd make a new issue, e.g. "Revert 1Password <g> workaround".

@victorlin
Copy link
Member Author

Good suggestion. Closing this now that the workaround has made its way to https://nextstrain.org and social posts have been shared to inform users.

@victorlin victorlin linked a pull request Jan 23, 2025 that will close this issue
8 tasks
@victorlin
Copy link
Member Author

Updating here since this issue has previous discussion of the source code.

1Password has added a fix in version 8.10.60.11, available through their nightly build. It looks like the fix is the addition of a condition (re-formatted with whitespace and the function name No from #1919 (comment)):

- No = e => e instanceof HTMLImageElement || e instanceof SVGElement                         || e instanceof HTMLCanvasElement
+ No = e => e instanceof HTMLImageElement || e instanceof SVGElement && e.nodeName === "svg" || e instanceof HTMLCanvasElement

Seems reasonable – conditioning the slow OTP detection feature on <svg>s prevents it from running on Auspice's thousands of <g>s, <rect>s, etc.

@tsibley
Copy link
Member

tsibley commented Jan 25, 2025

You got to diffing the source before I did. :-) Agreed that seems like a good fix. I'm a little surprised they didn't do it this way instead:

- No = e => e instanceof HTMLImageElement || e instanceof SVGElement    || e instanceof HTMLCanvasElement
+ No = e => e instanceof HTMLImageElement || e instanceof SVGSVGElement || e instanceof HTMLCanvasElement

but either way seems fine.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants