wxt-dev / wxt

⚡ Next-gen Web Extension Framework
https://wxt.dev
MIT License
4.79k stars 208 forks source link

Mount and unmount Content Script UI with MutationObserver #537

Closed hexpl0it closed 1 day ago

hexpl0it commented 9 months ago

For my extension I need to mount my app immediately after a precise div. However, this div is not immediately available when the page is loaded.

To do this I used MutationObserver:

import ReactDOM from "react-dom/client";
import Toolbar from "./components/Toolbar";
import { useEffect } from "react";

function waitForElm(selector) {
  return new Promise((resolve) => {
    if (document.querySelector(selector)) {
      return resolve(document.querySelector(selector));
    }
    const observer = new MutationObserver((mutations) => {
      if (document.querySelector(selector)) {
        observer.disconnect();
        resolve(document.querySelector(selector));
      }
    });
    observer.observe(document.body, {
      childList: true,
      subtree: true,
    });
  });
}

export default defineContentScript({
  main(ctx) {
    const ui = createIntegratedUi(ctx, {
      position: "inline",
      onMount: async (container) => {
        const elm = await waitForElm('[class^="ToolbarContainer__StyledHeader"]');
        const rootNode = document.createElement("div");
        elm.insertAdjacentElement("afterend", rootNode);
        const root = ReactDOM.createRoot(rootNode);
        root.render(<Toolbar />);
        return root;
      },
      onRemove: (root) => {
        root.unmount();
      },
    });

    ui.mount()
  },
});

However, this div can be removed following certain actions on the page. I would need to reassemble my component as soon as this div reappears. How can I do this?

aklinker1 commented 9 months ago

You'll want to use the awaited element as the anchor tag, and use the append option to append your UI using insertAdjacentElement.

Something like this would work. I typed this up in github comments, so it probably isn't valid JS, and I haven't tested it. But hopefully it gives you a better idea of what you're looking for.

let anchor;
const ui  = createIntegratedUi(ctx, {
  position: "inline",
  anchor: () => anchor,
  append: (anchor, root) => anchor.insertAdjacentElement("afterend", root),
  onMount: async (container) => {
    const root = ReactDOM.createRoot(container);
    root.render(<Toolbar />);
    return root;
  },
  onRemove: (root) => {
    root.unmount();
  },
});
watchDomChanges(ctx, '[class^="ToolbarContainer__StyledHeader"]', {
  onAdd: (newAnchor) => {
    anchor = newAnchor;
    ui.mount();
  },
  onRemove: () => {
    ui.remove();
  },
});
function watchDomChanges(ctx: any, selector: any, callbacks: any) {
  let prevAnchor: HTMLElement | undefined;

  const observer = new MutationObserver(() => {
    const el = document.querySelector(selector);
    if (el && !prevAnchor) {
      callbacks.onAdd(el);
    } else if (!el && prevAnchor) {
      callbacks.onRemove();
    }
    prevAnchor = el;
  });
  ctx.onInvalidated(() => observer.disconnect());
  observer.observe(document.body, {
    childList: true,
    subtree: true,
  });
  const initialEl = document.querySelector(selector);
  if (initialEl) {
    callbacks.onAdd(initialEl);
    prevAnchor = initialEl;
  }
}
aklinker1 commented 9 months ago

@hexpl0it If you get something that works, I'd like to add auto-mounting/unmounting to WXT, if you would like to contribute your implementation.

tesths commented 7 months ago

Thank you very much for this issue. I have recently encountered a similar problem. I want to inject content script above a specific div on the website after it has loaded. I have tried the methods, which are effective. Thanks for @aklinker1 . I wonder if there are other methods that can achieve similar functions? Maybe similar effects can be achieved without using an anchor?

aklinker1 commented 2 months ago

Think I'm gonna work on this soon. Here's what I'm thinking the API will look like:

const ui = createXyzUi({
  // ...
  anchor: "#some-anchor",
})

ui.autoMount();

Here's the types I expect:

type StopAutoMount = () => void;

interface UI {
  autoMount(options?: { once?: boolean }): StopAutoMount;
}
1natsu172 commented 2 months ago

I've been thinking about this Issue in the back of my mind for a long time. We get a lot of questions about dynamic UI mounts.

I just wonder what and how WXT should support. I have a feeling that autoMount will have a lot of responsibility since the timing and conditions are different for each application.

BTW: Actually, I have a personal library for dynamic mounting, lol.

aklinker1 commented 2 months ago

I have a feeling that autoMount will have a lot of responsibility since the timing and conditions are different for each.

Hmm, I recommended this function to addressing a single use case: mounting a UI inside a dynamic element that gets added and removed.

If someone wants a custom implementation, like listening to focus events, or adding timeouts, they should write those themselves using ui.mount and ui.unmount. that's why we provide those APIs.

BTW: Actually, I have a personal library for dynamic mounting, lol.

Nice! We can probably use it, does it support listening for when the element is removed from the DOM?

1natsu172 commented 2 months ago

If someone wants a custom implementation, like listening to focus events, or adding timeouts, they should write those themselves using ui.mount and ui.unmount. that's why we provide those APIs.

If you are set in your mind on that policy, it's ok 👍 . Writing in the document that it is a single use case would avoid confusion users.

does it support listening for when the element is removed from the DOM?

Supported by detector option. However, this library doesn't manage DOM state. This is an async wrapper for pure mutationObserver and querySelector. So if you want accuracy based on DOM state, will need a separate DOM state manager.

Basically, it goes like this. (recently, major bump to v4, so if there any bugs it might not work properly 😅 )

// waiting anchor
const anchor = await waitElement(targetAnchor);
if (anchor) {
  ui.mount();
}
// waiting remove
const nowAnchor = await waitElement(targetAnchor, { detector: isNotExist });
if (nowAnchor === null) {
  ui.unmount()
}
aklinker1 commented 4 weeks ago

I am onboard with adding functionality to WXT.

1natsu172 commented 3 weeks ago

Yeah, I’m willing to take this. I recently made my library support xpath for this issue :). I'm trying to write the test code first……but recently I've been so busy that I haven't had time. Need more time.

sgcullen commented 2 weeks ago

Interesting discussion - I'm doing similar stuff - following :-)

1natsu172 commented 2 weeks ago

@aklinker1 I'm working on the implementation a bit at a time. So we need to some decisions about the specs. About once option API, is this “only one mount” or “one mount and one remove”? Which did you expect?

And I think about the StopAutoMount, should be stopped when ui.remove is called, is there any discrepancy?

aklinker1 commented 2 weeks ago

About once option API, is this “only one mount” or “one mount and one remove”? Which did you expect?

I would expect it to be mounted and removed once.

And I think about the StopAutoMount, should be stopped when ui.remove is called, is there any discrepancy?

Yes, I agree with you on that. Calling ui.remove() should stop the auto-mount watcher as well.

1natsu172 commented 2 weeks ago

Okay, I'll go ahead with those specs for both!

aklinker1 commented 1 day ago

Release in v0.19.20: https://wxt.dev/guide/essentials/content-scripts.html#mounting-ui-to-dynamic-element