Content Script UI disappears immediately after appearing.
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
- [x] Read the Contributing Guidelines.
- [x] Read the docs.
- [x] Check that there isn't already an issue that reports the same bug to avoid creating a duplicate.
- [x] Check that this is a concrete bug. For Q&A open a GitHub Discussion or join our Discord Chat Server.
- [x] The provided reproduction is a minimal reproducible example of the bug.
@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
Yes, it works normally when I use the selector on body. It seems like the element is re-rendered every few seconds.
Yea soo in those cases you'll need to use MutationObserver and listen to changes to the UI and remount if it changes
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