Support multiple anchors in content script UI utils.
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
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
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?
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();
},
});
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()
}
}
}
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);
};
};
https://github.com/wxt-dev/wxt/issues/1704#issuecomment-2958397334
I created a component for this: react-magic-portal
+1 to natively supporting this 🙌🏽 Were doing some weird things with Portals/MutationObserver that is not a good solution 😅
+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.
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