wxt icon indicating copy to clipboard operation
wxt copied to clipboard

Support multiple anchors in content script UI utils.

Open aklinker1 opened this issue 7 months ago • 9 comments

Feature Request

Several requests for how to mount the same UI multiple times on list items or multiple other anchors on a page.

Is your feature request related to a bug?

  • https://discord.com/channels/1212416027611365476/1372609986877984798/1372609986877984798

What are the alternatives?

Using querySelectorAll and looping through all the elements, defining the UI mutliple times.

Additional context

N/A

aklinker1 avatar Jun 04 '25 14:06 aklinker1

This is my solution, to implement a DynamicPortal component that supports passing in a mount point, using ReactDOM.createPortal to mount some content-script widgets to world: "MAIN". The only drawback is that if you need to copy/import a CSS to world: "MAIN", otherwise you can only write inline styles.

DynamicPortal.tsx

import * as React from 'react'
import ReactDOM from 'react-dom'
import { Primitive } from '@radix-ui/react-primitive'
import { ComponentRef, ComponentPropsWithoutRef, useEffect, useState, useRef, useCallback } from 'react'
type PortalElement = ComponentRef<typeof Primitive.div>
type PrimitiveDivProps = ComponentPropsWithoutRef<typeof Primitive.div>

export interface DynamicPortalProps extends PrimitiveDivProps {
  /**
   * An optional container where the portaled content should be appended.
   */
  container: string | (() => Element | null)
}

const DynamicPortal = ({
  ref: forwardedRef,
  ...props
}: DynamicPortalProps & { ref?: React.RefObject<PortalElement | null> }) => {
  const { container, ...portalProps } = props

  const [dynamicContainer, setDynamicContainer] = useState<Element | null>(null)
  const dynamicContainerRef = useRef<Element | null>(null)

  const update = useCallback(() => {
    const newContainer = typeof container === 'string' ? document.querySelector(container) : container()

    if (newContainer !== dynamicContainerRef.current) {
      dynamicContainerRef.current = newContainer
      // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
      setDynamicContainer(newContainer)
    }
  }, [container])

  useEffect(() => {
    update()
    const observer = new MutationObserver((mutations) => {
      const isSelfMutation = mutations
        .flatMap(({ addedNodes, removedNodes }) => [...addedNodes, ...removedNodes])
        .some((node) => dynamicContainerRef.current?.contains(node))
      !isSelfMutation && update()
    })

    observer.observe(document, { childList: true, subtree: true })
    return () => {
      observer.disconnect()
    }
  }, [container])

  return dynamicContainer
    ? ReactDOM.createPortal(<Primitive.div {...portalProps} ref={forwardedRef} />, dynamicContainer)
    : null
}
DynamicPortal.displayName = 'DynamicPortal'
export default DynamicPortal

Usage

<DynamicPortal asChild container={'#xxx-root'}>
   <h1 className="text-xl">Hello Word</h1>
</DynamicPortal>

In addition, when copying tailwind@v4 to "MAIN", to avoid conflicts with CSS variable names, I solved it like this:

.postcssrc

{
  "plugins": {
    "@tailwindcss/postcss": {},
    "postcss-rem-to-responsive-pixel": {
      "rootValue": 16,
      "propList": [
        "*"
      ],
      "transformUnit": "px"
    },
+    "postcss-variables-prefixer": {
+      "prefix": "tw-"
+    }
  }
}

Using postcss to add a prefix to Tailwind variables

molvqingtai avatar Jun 10 '25 09:06 molvqingtai

This is my solution, to implement a DynamicPortal component that supports passing in a mount point, using ReactDOM.createPortal to mount some content-script widgets to world: "MAIN". The only drawback is that if you need to copy a CSS to world: "MAIN", otherwise you can only write inline styles.

DynamicPortal.tsx

import * as React from 'react' import ReactDOM from 'react-dom' import { Primitive } from '@radix-ui/react-primitive' import { ComponentRef, ComponentPropsWithoutRef, useEffect, useState, useRef, useCallback } from 'react' type PortalElement = ComponentRef<typeof Primitive.div> type PrimitiveDivProps = ComponentPropsWithoutRef<typeof Primitive.div>

export interface DynamicPortalProps extends PrimitiveDivProps { /**

  • An optional container where the portaled content should be appended. */ container: string | (() => Element | null) }

const DynamicPortal = ({ ref: forwardedRef, ...props }: DynamicPortalProps & { ref?: React.RefObject<PortalElement | null> }) => { const { container, ...portalProps } = props

const [dynamicContainer, setDynamicContainer] = useState<Element | null>(null) const dynamicContainerRef = useRef<Element | null>(null)

const update = useCallback(() => { const newContainer = typeof container === 'string' ? document.querySelector(container) : container()

if (newContainer !== dynamicContainerRef.current) {
  dynamicContainerRef.current = newContainer
  // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
  setDynamicContainer(newContainer)
}

}, [container])

useEffect(() => { update() const observer = new MutationObserver((mutations) => { const isSelfMutation = mutations .flatMap(({ addedNodes, removedNodes }) => [...addedNodes, ...removedNodes]) .some((node) => dynamicContainerRef.current?.contains(node)) !isSelfMutation && update() })

observer.observe(document, { childList: true, subtree: true })
return () => {
  observer.disconnect()
}

}, [container])

return dynamicContainer ? ReactDOM.createPortal(<Primitive.div {...portalProps} ref={forwardedRef} />, dynamicContainer) : null } DynamicPortal.displayName = 'DynamicPortal' export default DynamicPortal Usage

<DynamicPortal asChild container={'#xxx-root'}>

Hello Word

In addition, when copying tailwind@v4 to "MAIN", to avoid conflicts with CSS variable names, I solved it like this:

.postcssrc

{ "plugins": { "@tailwindcss/postcss": {}, "postcss-rem-to-responsive-pixel": { "rootValue": 16, "propList": [ "*" ], "transformUnit": "px" },

  • "postcss-variables-prefixer": {
  •  "prefix": "tw-"
    
  • } } } Using postcss to add a prefix to Tailwind variables

@aklinker1 That's solve issue or it's still in progress?

PatrykKuniczak avatar Jul 05 '25 11:07 PatrykKuniczak

This is my solution, to implement a DynamicPortal component that supports passing in a mount point, using ReactDOM.createPortal to mount some content-script widgets to world: "MAIN". The only drawback is that if you need to copy/import a CSS to world: "MAIN", otherwise you can only write inline styles.

DynamicPortal.tsx

import * as React from 'react' import ReactDOM from 'react-dom' import { Primitive } from '@radix-ui/react-primitive' import { ComponentRef, ComponentPropsWithoutRef, useEffect, useState, useRef, useCallback } from 'react' type PortalElement = ComponentRef<typeof Primitive.div> type PrimitiveDivProps = ComponentPropsWithoutRef<typeof Primitive.div>

export interface DynamicPortalProps extends PrimitiveDivProps { /**

  • An optional container where the portaled content should be appended. */ container: string | (() => Element | null) }

const DynamicPortal = ({ ref: forwardedRef, ...props }: DynamicPortalProps & { ref?: React.RefObject<PortalElement | null> }) => { const { container, ...portalProps } = props

const [dynamicContainer, setDynamicContainer] = useState<Element | null>(null) const dynamicContainerRef = useRef<Element | null>(null)

const update = useCallback(() => { const newContainer = typeof container === 'string' ? document.querySelector(container) : container()

if (newContainer !== dynamicContainerRef.current) {
  dynamicContainerRef.current = newContainer
  // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
  setDynamicContainer(newContainer)
}

}, [container])

useEffect(() => { update() const observer = new MutationObserver((mutations) => { const isSelfMutation = mutations .flatMap(({ addedNodes, removedNodes }) => [...addedNodes, ...removedNodes]) .some((node) => dynamicContainerRef.current?.contains(node)) !isSelfMutation && update() })

observer.observe(document, { childList: true, subtree: true })
return () => {
  observer.disconnect()
}

}, [container])

return dynamicContainer ? ReactDOM.createPortal(<Primitive.div {...portalProps} ref={forwardedRef} />, dynamicContainer) : null } DynamicPortal.displayName = 'DynamicPortal' export default DynamicPortal Usage

<DynamicPortal asChild container={'#xxx-root'}>

Hello Word

In addition, when copying tailwind@v4 to "MAIN", to avoid conflicts with CSS variable names, I solved it like this:

.postcssrc

{ "plugins": { "@tailwindcss/postcss": {}, "postcss-rem-to-responsive-pixel": { "rootValue": 16, "propList": [ "*" ], "transformUnit": "px" },

  • "postcss-variables-prefixer": {
  •  "prefix": "tw-"
    
  • } } } Using postcss to add a prefix to Tailwind variables

I'm confused what is the correlation? this code example doesn't give me a clear understanding for the end goal, I'm still confused.

before I used plasmo, and they provide the easiest way, just use the following code

export const getInlineAnchorList: PlasmoGetInlineAnchorList = async() =>
 document.querySelectorAll("a")

i hope like this

export default defineContentScript({
  matches: ["*://*/*"],
  cssInjectionMode: "ui",
  async main(ctx) {
    const ui = await createShadowRootUi(ctx, {
      name: "wrapper",
      position: "inline",
      anchor: ".image-card", // support with multiple elements
      append: "first",
      onMount: (container) => {
      },
    });
    ui.mount();
  },
});

murnifine avatar Jul 15 '25 18:07 murnifine

Plasmo implementation is at https://github.com/PlasmoHQ/plasmo/blob/main/cli/plasmo/templates/static/common/csui.ts Which could be roughly translated into something like

function createIntegratedListUi<TMounted>(
    ctx: ContentScriptContext,
    options: Omit<IntegratedContentScriptUiOptions<TMounted>, "anchor"> & {anchor: () => Element[]}
): BaseMountFunctions {
    const mounted = new Set<Element>()
    const uiMap = new WeakMap<Element, IntegratedContentScriptUi<TMounted>>()

    function mountAnchors() {
        for (const anchor of options.anchor()) {
            const ui = createIntegratedUi(ctx, {
                ...options,
                anchor: anchor
            });
            mounted.add(anchor)
            uiMap.set(anchor, ui)
            ui.mount()
        }
    }

    const observer = new MutationObserver(() => {
        for (const element of mounted) {
            if (!document.contains(element)) {
                uiMap.get(element)?.remove()
                mounted.delete(element)
            }
        }
        mountAnchors()
    });

    return {
        mount: () => {
            mountAnchors()
            observer.observe(document.documentElement, {childList: true, subtree: true})
        },
        remove: () => {
            observer.disconnect()
            for (const element of mounted) {
                uiMap.get(element)?.remove()
            }
            mounted.clear()
        }
    }
}

absdjfh avatar Jul 15 '25 20:07 absdjfh

up on this,

export default defineContentScript({
  matches: ["*://*/*"],
  cssInjectionMode: "ui",
  async main(ctx) {
    const ui = await createShadowRootUi(ctx, {
      name: "wrapper",
      position: "inline",
      anchor: ".image-card", // support with multiple elements
      append: "first",
      onMount: (container) => {
      },
    });
    ui.mount();
  },
});

I wish i could just do this, unstead here is my currentSolution :

import ReactDOM from 'react-dom/client';
import App from '@lib/app/thread-page/snippets-button/app.tsx';
import '@lib/app/thread-page/snippets-button/style.css';

import { i18nConfig } from '@lib/components/i18nConfig.ts';
import initTranslations from '@lib/components/i18n.ts';
import { TooltipProvider } from '@lib/components/ui/tooltip.tsx';
import GlobalProvider from '@lib/components/global-provider.tsx';
import {
    DEFAULT_WAIT_FOR_ELEMENT_TIMEOUT,
    isAlreadyInjected,
    removeElementDuplicates,
    waitFor,
    waitForSelector,
} from '@lib/utils';
import {
    isFeatureAvailable,
    listenToFeature,
} from '@lib/services/growthbook.service';

export default defineContentScript({
    matches: ['*://mail.google.com/mail/u/*'],
    cssInjectionMode: 'ui',
    async main(ctx) {
        let ui: any;

        if (isAlreadyInjected('reccap-snippets')) {
            return;
        }

        ui = await mountUi(ctx);
    },
});

const checkIsInAnswerPage = (element: HTMLElement) => {
    const replyMessageContainer = document.querySelector(
        '.adn.ads[data-message-id]',
    );
    if (replyMessageContainer) {
        const parentContainer = replyMessageContainer.closest('.nH');
        if (parentContainer) {
            const composeElement = parentContainer.querySelector(
                'div[data-compose-id]',
            );
            return element.closest('div[data-compose-id]') === composeElement;
        }
    }
    return false;
};

const mountUi = async (ctx: any) => {
    initTranslations(i18nConfig.defaultLocale, ['common', 'content']);
    const allUis: any[] = [];
    if (isAlreadyInjected('reccap-snippets-root')) {
        return;
    }
    const ui = await createShadowRootUi(ctx, {
        name: 'reccap-snippets-root',
        anchor: 'body',
        append: 'after',
        position: 'inline',
        onMount: async (container) => {
            const root = ReactDOM.createRoot(container);
            await waitForSelector({
                selector: 'tr.btC > td:nth-child(5)',
                timeout: DEFAULT_WAIT_FOR_ELEMENT_TIMEOUT,
                afterFound: async (stop) => {
                    const isAvailable =
                        await isFeatureAvailable('ext-snippets-btn');
                    if (!isAvailable) {
                        stop();
                        return;
                    }
                    const elements = document.querySelectorAll(
                        'tr.btC > td:nth-child(5)',
                    );
                    // add the snippets to every element
                    for (const element of elements) {
                        const isInAnswerPage = checkIsInAnswerPage(
                            element as HTMLElement,
                        );
                        if (
                            document?.querySelector(
                                `reccap-snippets${isInAnswerPage ? '-answer' : '-compose'}`,
                            )
                        ) {
                            return;
                        }

                        const newUi = await createShadowRootUi(ctx, {
                            name: `reccap-snippets${isInAnswerPage ? '-answer' : '-compose'}`,
                            anchor: element,
                            append: 'after',
                            position: 'inline',
                            onMount: (c) => {
                                const r = ReactDOM.createRoot(c);
                                r.render(
                                    <GlobalProvider>
                                        <TooltipProvider>
                                            <App />
                                        </TooltipProvider>
                                    </GlobalProvider>,
                                );
                                return r;
                            },
                        });

                        newUi?.mount();
                        allUis.push(newUi);
                        await waitFor(100);
                    }
                },
            });

            return root;
        },
    });

    ui.mount();

    const interval = listenToFeature('ext-snippets-btn', ui, () => {
        allUis.forEach((ui) => {
            ui.remove();
        });
    });

    // check if multiple elements are present
    const interval2 = removeElementDuplicates([
        'reccap-snippets-answer',
        'reccap-snippets-compose',
        'reccap-send-button-root',
    ]);

    return () => {
        clearInterval(interval);
        clearInterval(interval2);
    };
};

Rafik-Belkadi-Reccap avatar Sep 12 '25 15:09 Rafik-Belkadi-Reccap

https://github.com/wxt-dev/wxt/issues/1704#issuecomment-2958397334

I created a component for this: react-magic-portal

molvqingtai avatar Sep 24 '25 08:09 molvqingtai

+1 to natively supporting this 🙌🏽 Were doing some weird things with Portals/MutationObserver that is not a good solution 😅

marcellino-ornelas avatar Oct 02 '25 22:10 marcellino-ornelas

+1 to natively supporting this 🙌🏽 Were doing some weird things with Portals/MutationObserver that is not a good solution 😅

Allow multiple anchors to share the same React instance (root), rather than creating a React root node for each anchor, In addition to using portals, I can't think of any better ideas.

molvqingtai avatar Oct 03 '25 04:10 molvqingtai

if anyone is interested Ive started using this homemade hook to listen to elements and update when they change. This is a great way to create a portal to the element. Probably have some bugs and could be improved but soo far been working pretty good 🤷🏽‍♂️

import { useEffect, useRef, useState } from 'react';
import { useEffectOnce, useDeepCompareEffect } from 'react-use';

/**
 * React hook to observe DOM and return all elements matching a selector.
 * Updates whenever matching elements are added, removed, or replaced.
 *
 * @param selector - CSS selector to watch
 * @param opts - Options for the hook
 * @returns Array of matching HTMLElements (empty if none)
 */
export function useWatchedElements<T extends HTMLElement = HTMLElement>(
    selector: string,
    opts: UseWatchedElementsOptions = DEFAULT_OPTIONS
): T[] {
    const [elements, setElements] = useState<T[]>(() =>
        Array.from(document.querySelectorAll<T>(selector))
    );
    const [fullOptions, setFullOptions] = useState<UseWatchedElementsOptions>(
        () => ({ ...DEFAULT_OPTIONS, ...opts })
    );

    // Can be expensive but options arent super deep or big soo this is fine
    // only needed because `attributeFilter` is a array and wont work with shallow compare
    useDeepCompareEffect(() => {
        setFullOptions({ ...DEFAULT_OPTIONS, ...opts });
    }, [opts]);

    const updateElements = useCallback(() => {
        const els = Array.from(document.querySelectorAll<T>(selector));
        setElements(els);
    }, [selector]);

    // Only update elements after the initial render
    useEffectOnce(() => {
        updateElements();
    });

    useEffect(() => {
        const observer = new MutationObserver((mutationList) => {
            for (const mutation of mutationList) {
                // TODO:
                //   we eventually can accept a `onMutation` callback that can
                //   be used to handle the update in a more custom way
                switch (mutation.type) {
                    case 'childList':
                        // TODO:
                        //   we eventually can accept a `onChildListMutation` callback that can
                        //   be used to handle the update in a more custom way
                        for (const node of [
                            ...mutation.addedNodes,
                            ...mutation.removedNodes,
                        ]) {
                            if (
                                fullOptions.ignoreChildListNodes?.some(
                                    (selector) => matchesElement(node, selector)
                                )
                            ) {
                                break;
                            }

                            if (
                                // Check if the added/removed node matches the selector
                                matchesElement(node, selector) ||
                                // Check to see if the target node matches the selector. Child elements getting updated
                                matchesElement(mutation.target, selector) ||
                                // Check for any other additional target selectors
                                fullOptions.additionalTargetSelectors?.some(
                                    (selector) =>
                                        matchesElement(
                                            mutation.target,
                                            selector
                                        )
                                )
                            ) {
                                updateElements();

                                // If we already are updating the elements, we
                                // dont need to worry about other mutations so
                                // return to break the loop
                                return;
                            }
                        }

                        break;
                    case 'attributes':
                        // TODO:
                        //   we eventually can accept a `onAttributeMutation` callback that can
                        //   be used to handle the update in a more custom way

                        // TODO: Should we also check for additional target selectors?
                        if (!matchesElement(mutation.target, selector)) break;
                        if (!mutation.attributeName) break;

                        if (
                            // Only update if the attribute has actually changed
                            mutation.target.getAttribute(
                                mutation.attributeName
                            ) !== mutation.oldValue
                        ) {
                            updateElements();

                            // If we already are updating the elements, we
                            // dont need to worry about other mutations so
                            // return to break the loop
                            return;
                        }
                        break;
                    default:
                        break;
                }
            }
        });

        // TODO:
        //   can we optimize this by targeting a specific parent element?
        //   The main issue is what happens when that element is removed from the DOM?
        observer.observe(document.body, {
            ...fullOptions,
        });

        return () => observer.disconnect();
    }, [selector, fullOptions, updateElements]);

    return elements;
}

happy to share a version of our portal code if anyone is interested

marcellino-ornelas avatar Oct 21 '25 23:10 marcellino-ornelas