wxt-dev / wxt

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

Mount and unmount Content Script UI with MutationObserver #537

Open hexpl0it opened 6 months ago

hexpl0it commented 6 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 6 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 6 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 4 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 1 week 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 1 week 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 1 week 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 1 week 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()
}