wxt icon indicating copy to clipboard operation
wxt copied to clipboard

Content Script UI disappears immediately after appearing.

Open murnifine opened this issue 4 months ago • 4 comments

Describe the bug

I spent the whole day looking for a solution to this problem. The elements appeared but then immediately disappeared.

Previously, I used Plasmo and it worked fine in the same selector.

web: https://www.istockphoto.com/en/search/2/image-film?phrase=water%20drink

https://github.com/user-attachments/assets/f86b0f93-26ac-4df1-b2d7-664fe364154e

import "~/assets/tailwind.css";

import ReactDOM from "react-dom/client";

export default defineContentScript({
  matches: ["https://www.istockphoto.com/*"],
  cssInjectionMode: "ui",
  runAt: "document_end",
  async main(ctx) {
    const ui = await createShadowRootUi(ctx, {
      name: "button-analyze-on-article",
      position: "inline",
      append: "first",
      anchor: "[data-testid='galleryContainer']",
      onMount: (container) => {
        const app = document.createElement("div");
        container.append(app);
        const root = ReactDOM.createRoot(app);
        root.render(
          <button className="bg-blue-500 text-white p-2 rounded-md">
            Analyze
          </button>
        );
        return root;
      },
      onRemove: (root) => {
        root?.unmount();
      },
    });

    ui.autoMount();
  },
});

Reproduction

Steps to reproduce

run dev

System Info

System:
    OS: Windows 11 10.0.26100
    CPU: (16) x64 AMD Ryzen 7 7840HS w/ Radeon 780M Graphics     
    Memory: 3.43 GB / 31.19 GB
  Binaries:
    Node: 22.14.0 - C:\Program Files\nodejs\node.EXE
    npm: 10.9.2 - C:\Program Files\nodejs\npm.CMD
    pnpm: 10.14.0 - ~\AppData\Local\pnpm\pnpm.CMD
    bun: 1.2.12 - ~\.bun\bin\bun.EXE
  Browsers:
    Edge: Chromium (140.0.3485.54)
  npmPackages:
    wxt: ^0.20.11 => 0.20.11

Used Package Manager

bun

Validations

murnifine avatar Sep 19 '25 12:09 murnifine

@murnifine Have you tried adding the element somewhere else and seeing if it does the same behavior? Try someone more static for now like top of the images and videos section too see if this still happen.

What this looks like to me is that their website is probably using something like react and when react updates it removes all elements you add to the screen. This is super tricky to handle to be honest and needs more logic to be able to get done. We build a hook called useWatchElements that listens to changes to DOM elements but we haven't perfected it yet soo not going to share here at the moment

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

Yes, it works normally when I use the selector on body. It seems like the element is re-rendered every few seconds.

murnifine avatar Oct 06 '25 14:10 murnifine

Yea soo in those cases you'll need to use MutationObserver and listen to changes to the UI and remount if it changes

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

For anyone else running into something like this, this is currently are using a home made useWatchedElements hook that listen to elements changing:

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;
}

then we use these returned elements and create portals to them using React.createPortal which we inject react elements too. This has been working ok soo far for element that get updated and for injecting react elements to a list of elements like whats being asked about here

https://github.com/wxt-dev/wxt/issues/1704

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