diff --git a/packages/external-plugin-previewer/src/playground.ts b/packages/external-plugin-previewer/src/playground.ts index 176bdb0e42af..40bc2d15f22d 100644 --- a/packages/external-plugin-previewer/src/playground.ts +++ b/packages/external-plugin-previewer/src/playground.ts @@ -1,7 +1,7 @@ import React from 'react' import { t } from 'ef.js' import { setupPortalShadowRoot } from '@masknet/theme' -setupPortalShadowRoot({ mode: 'open' }, []) +setupPortalShadowRoot({ mode: 'open' }) Object.assign(globalThis, { React }) diff --git a/packages/mask/src/plugins/ITO/SNSAdaptor/CompositionDialog.tsx b/packages/mask/src/plugins/ITO/SNSAdaptor/CompositionDialog.tsx index 9acb3d152bfc..70e36f80b125 100644 --- a/packages/mask/src/plugins/ITO/SNSAdaptor/CompositionDialog.tsx +++ b/packages/mask/src/plugins/ITO/SNSAdaptor/CompositionDialog.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from 'react' import Web3Utils from 'web3-utils' import { DialogContent } from '@mui/material' -import { makeStyles, usePortalShadowRoot } from '@masknet/theme' +import { makeStyles } from '@masknet/theme' import { useI18N } from '../../../utils' import { useRemoteControlledDialog } from '@masknet/shared' import { InjectedDialog, InjectedDialogProps } from '../../../components/shared/InjectedDialog' @@ -201,14 +201,14 @@ export function CompositionDialog(props: CompositionDialogProps) { tabs: [ { label: t('plugin_ito_create_new'), - children: usePortalShadowRoot(() => ( + children: ( - )), + ), sx: { p: 0 }, }, { diff --git a/packages/mask/src/social-network/defaults/inject/PageInspector.tsx b/packages/mask/src/social-network/defaults/inject/PageInspector.tsx index 6f5c1f21f245..415e765425c3 100644 --- a/packages/mask/src/social-network/defaults/inject/PageInspector.tsx +++ b/packages/mask/src/social-network/defaults/inject/PageInspector.tsx @@ -2,8 +2,7 @@ import { memo } from 'react' import { makeStyles } from '@masknet/theme' import { PageInspector, PageInspectorProps } from '../../../components/InjectedComponents/PageInspector' import { createReactRootShadowed } from '../../../utils/shadow-root/renderInShadowRoot' -import { MutationObserverWatcher, LiveSelector } from '@dimensiondev/holoflows-kit' -import { startWatch } from '../../../utils/watcher' +import { Flags } from '../../../../shared' export function injectPageInspectorDefault( config: InjectPageInspectorDefaultConfig = {}, @@ -17,9 +16,11 @@ export function injectPageInspectorDefault( }) return function injectPageInspector(signal: AbortSignal) { - const watcher = new MutationObserverWatcher(new LiveSelector().querySelector('body')) - startWatch(watcher, signal) - createReactRootShadowed(watcher.firstDOMProxy.afterShadow, { signal }).render() + const dom = document.body + .appendChild(document.createElement('div')) + .attachShadow({ mode: Flags.using_ShadowDOM_attach_mode }) + + createReactRootShadowed(dom, { signal, key: 'page-inspector' }).render() } } diff --git a/packages/mask/src/social-network/defaults/inject/StartSetupGuide.tsx b/packages/mask/src/social-network/defaults/inject/StartSetupGuide.tsx index e4e56de99d8b..549f0ed3da0e 100644 --- a/packages/mask/src/social-network/defaults/inject/StartSetupGuide.tsx +++ b/packages/mask/src/social-network/defaults/inject/StartSetupGuide.tsx @@ -10,12 +10,11 @@ function UI({ unmount, persona }: { unmount: () => void; persona: PersonaIdentif export function createTaskStartSetupGuideDefault() { let shadowRoot: ShadowRoot return (signal: AbortSignal, for_: PersonaIdentifier) => { - const dom = document.createElement('span') - document.body.appendChild(dom) - const root = createReactRootShadowed( - shadowRoot || (shadowRoot = dom.attachShadow({ mode: Flags.using_ShadowDOM_attach_mode })), - { signal }, - ) + shadowRoot ??= document.body + .appendChild(document.createElement('div')) + .attachShadow({ mode: Flags.using_ShadowDOM_attach_mode }) + + const root = createReactRootShadowed(shadowRoot, { signal, key: 'setup-guide' }) root.render( root.destory()} />) } } diff --git a/packages/mask/src/utils/shadow-root/renderInShadowRoot.tsx b/packages/mask/src/utils/shadow-root/renderInShadowRoot.tsx index be4be4242674..2452bb95ddff 100644 --- a/packages/mask/src/utils/shadow-root/renderInShadowRoot.tsx +++ b/packages/mask/src/utils/shadow-root/renderInShadowRoot.tsx @@ -2,7 +2,6 @@ import { createReactRootShadowedPartial, setupPortalShadowRoot, CSSVariableInjec import { Flags } from '../../../shared' import { MaskUIRoot } from '../../UIRoot' import { useClassicMaskSNSTheme } from '../theme' -import { createRoot } from 'react-dom' const captureEvents: (keyof HTMLElementEventMap)[] = [ 'paste', @@ -18,13 +17,7 @@ const captureEvents: (keyof HTMLElementEventMap)[] = [ 'change', ] export const setupShadowRootPortal = () => { - const shadow = setupPortalShadowRoot({ mode: Flags.using_ShadowDOM_attach_mode }, captureEvents) - createRoot(shadow.appendChild(document.createElement('head'))).render( -
- - -
, - ) + setupPortalShadowRoot({ mode: Flags.using_ShadowDOM_attach_mode }) } // https://github.com/DimensionDev/Maskbook/issues/3265 with fast refresh or import order? diff --git a/packages/plugin-infra/src/utils/createInjectHooksRenderer.tsx b/packages/plugin-infra/src/utils/createInjectHooksRenderer.tsx index e106fb977eb0..6adfdb8c502d 100644 --- a/packages/plugin-infra/src/utils/createInjectHooksRenderer.tsx +++ b/packages/plugin-infra/src/utils/createInjectHooksRenderer.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, useRef, memo, createContext, useContext } from 're import { ErrorBoundary } from '@masknet/shared' import { usePluginI18NField, PluginWrapperComponent, PluginWrapperMethods } from '../hooks' import { emptyPluginWrapperMethods, PluginWrapperMethodsContext } from '../hooks/usePluginWrapper' +import { ShadowRootIsolation } from '@masknet/theme' type Inject = Plugin.InjectUI type Raw = Plugin.InjectUIRaw @@ -44,16 +45,20 @@ export function createInjectHooksRenderer ( - + + + )) return <>{all} } return memo(function PluginsInjectionHookRenderErrorBoundary(props: PropsType) { return ( - - - + + + + + ) }) } diff --git a/packages/plugins/FileService/src/SNSAdaptor/components/UploadDropArea.tsx b/packages/plugins/FileService/src/SNSAdaptor/components/UploadDropArea.tsx index d9607b678cb3..22734badd051 100644 --- a/packages/plugins/FileService/src/SNSAdaptor/components/UploadDropArea.tsx +++ b/packages/plugins/FileService/src/SNSAdaptor/components/UploadDropArea.tsx @@ -40,7 +40,6 @@ const useStyles = makeStyles()((theme) => ({ borderColor: '#2CA4EF', borderStyle: 'solid', userSelect: 'none', - '& > $indicator': { opacity: 1 }, }, indicator: { position: 'absolute', diff --git a/packages/polyfills/types/dom.d.ts b/packages/polyfills/types/dom.d.ts index bb8e185efa39..832a89dd75b6 100644 --- a/packages/polyfills/types/dom.d.ts +++ b/packages/polyfills/types/dom.d.ts @@ -12,3 +12,11 @@ interface ImportMeta { */ url: string } + +interface ShadowRoot { + adoptedStyleSheets?: readonly CSSStyleSheet[] +} +interface CSSStyleSheet { + replace(text: string): Promise + replaceSync(text: string): void +} diff --git a/packages/theme/src/CSSVariableInjector.tsx b/packages/theme/src/CSSVariableInjector.tsx new file mode 100644 index 000000000000..feee253efdd5 --- /dev/null +++ b/packages/theme/src/CSSVariableInjector.tsx @@ -0,0 +1,10 @@ +import { GlobalStyles, Theme, useTheme } from '@mui/material' +import { useRef } from 'react' +import { CSSVariableInjectorCSS } from './constants' + +export function CSSVariableInjector(props: React.PropsWithChildren<{ useTheme?: () => Theme }>) { + const { current: useConsistentTheme } = useRef(props.useTheme || useTheme) + const colorScheme = useConsistentTheme().palette.mode + + return +} diff --git a/packages/theme/src/ShadowRoot/CSSVariableInjector.tsx b/packages/theme/src/ShadowRoot/CSSVariableInjector.tsx deleted file mode 100644 index 05b0a73945c2..000000000000 --- a/packages/theme/src/ShadowRoot/CSSVariableInjector.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { applyMaskColorVars } from '../' -import { Theme, useTheme } from '@mui/material' -import { useLayoutEffect, useRef } from 'react' - -export function CSSVariableInjector(props: React.PropsWithChildren<{ useTheme?: () => Theme }>) { - const ref = useRef(null) - const { current: useConsistentTheme } = useRef(props.useTheme || useTheme) - const colorScheme = useConsistentTheme().palette.mode - - useLayoutEffect(() => { - const host = ref.current!.closest('main')!.parentNode!.querySelector('head')! - let style: HTMLStyleElement = host.querySelector('style[data-css-var]')! - if (!style) { - host.insertBefore((style = document.createElement('style')), host.firstChild) - style.dataset.cssVar = '' - } - - applyMaskColorVars(style, colorScheme) - }, [colorScheme]) - return -} diff --git a/packages/theme/src/ShadowRoot/Contexts.ts b/packages/theme/src/ShadowRoot/Contexts.ts new file mode 100644 index 000000000000..0a272a338148 --- /dev/null +++ b/packages/theme/src/ShadowRoot/Contexts.ts @@ -0,0 +1,7 @@ +import { createContext } from 'react' +import type { StyleSheet } from './ShadowRootStyleSheet' + +/** @internal */ +export const StyleSheetsContext = createContext(null!) +/** @internal */ +export const PreventEventPropagationListContext = createContext<(keyof HTMLElementEventMap)[]>([]) diff --git a/packages/theme/src/ShadowRoot/Portal.tsx b/packages/theme/src/ShadowRoot/Portal.tsx index 9e88a0845eae..67d149ddb604 100644 --- a/packages/theme/src/ShadowRoot/Portal.tsx +++ b/packages/theme/src/ShadowRoot/Portal.tsx @@ -1,25 +1,15 @@ -import { useRef, useEffect, forwardRef, useState, createContext, useContext } from 'react' -import { useCurrentShadowRootStyles } from './ShadowRootStyleProvider' +/* eslint-disable react-hooks/rules-of-hooks */ +import { useRef, forwardRef, createContext, useContext, useEffect } from 'react' import type { PopperProps } from '@mui/material' +import { PreventEventPropagationListContext, StyleSheetsContext } from './Contexts' -/** - * ! Do not export ! - * - * You SHOULD NOT use this in React directly - */ let mountingPoint: HTMLDivElement let mountingShadowRoot: ShadowRoot -export function setupPortalShadowRoot( - init: ShadowRootInit, - preventEventPropagationList: (keyof HTMLElementEventMap)[], -) { - if (mountingPoint) return mountingShadowRoot! +export function setupPortalShadowRoot(init: ShadowRootInit) { + if (mountingShadowRoot) return mountingShadowRoot mountingShadowRoot = document.body.appendChild(document.createElement('div')).attachShadow(init) - for (const each of preventEventPropagationList) { - mountingShadowRoot.addEventListener(each, (e) => e.stopPropagation()) - } mountingPoint = mountingShadowRoot.appendChild(document.createElement('div')) - return mountingShadowRoot! + return mountingShadowRoot } /** usePortalShadowRoot under this context does not do anything. (And it will return an empty container). */ @@ -41,64 +31,56 @@ export const NoEffectUsePortalShadowRootContext = createContext(false) * /> * )) */ -export function usePortalShadowRoot(renderer: (container: HTMLDivElement | undefined) => null | JSX.Element) { +export function usePortalShadowRoot(renderer: (container: HTMLElement | undefined) => null | JSX.Element) { + const disabled = useRef(useContext(NoEffectUsePortalShadowRootContext)).current // we ignore the changes on this property during multiple render // so we can violates the React hooks rule and still be safe. - const disabled = useRef(useContext(NoEffectUsePortalShadowRootContext)).current if (disabled) return renderer(undefined) - // eslint-disable-next-line react-hooks/rules-of-hooks - const [findMountingShadowRef, setRef] = useState(null) - // eslint-disable-next-line react-hooks/rules-of-hooks - const doms = useSideEffectRef(() => { - const root = document.createElement('div') - const container = root.appendChild(document.createElement('div')) - const style = root.appendChild(document.createElement('style')) - return { root, container, style } - }) - const { container } = doms + const sheets = useContext(StyleSheetsContext) + const signal = useRef(null!) + const preventEventPropagationList = useContext(PreventEventPropagationListContext) + const { container, root } = useRefInit(() => { + signal.current = new AbortController() + const portal = PortalShadowRoot() - return ( - - findMountingShadowRef !== ref && setRef(ref)} /> - {renderer(container)} - - ) -} + const root = document.createElement('div') + root.dataset.portalShadowRoot = '' + const shadow = root.attachShadow({ mode: 'open' }) -/* -Here is a strange problem that `useMemo` in the `useCurrentShadowRootStyles` will re-render every event loop _even_ findMountingShadowRef is the same. -React is isolating their render process in the unit of components. Split it into another component solves the problem. -(And now it no longer re-render every event loop). - */ -type IsolatedRenderProps = React.PropsWithChildren<{ - root: HTMLElement - container: HTMLElement - style: HTMLStyleElement - findMountingShadowRef: HTMLSpanElement | null -}> -const IsolatedRender = ({ container, root, style, children, findMountingShadowRef }: IsolatedRenderProps) => { - const update = useUpdate() - const css = useCurrentShadowRootStyles(findMountingShadowRef) - const containerInUse = container.children.length !== 0 + const stop = (e: Event): void => e.stopPropagation() + for (const each of preventEventPropagationList) { + shadow.addEventListener(each, stop, { signal: signal.current.signal }) + } - useEffect(() => { - container.appendChild = bind(container.appendChild, container, update) - container.removeChild = bind(container.removeChild, container, update) - }, []) + const container = shadow.appendChild(document.createElement('main')) + sheets.map((x) => x.addContainer(shadow)) - useEffect(() => { - if (!containerInUse) return root.remove() - const shadow = PortalShadowRoot() - if (root.parentElement === shadow) return - shadow.appendChild(root) - }, [containerInUse, root]) + // This is proved to be important to the correct portal orders... + container.appendChild = (child) => { + if (!root.parentElement) portal.appendChild(root) + Node.prototype.appendChild.call(container, child) + return child + } + container.removeChild = (child) => { + Node.prototype.removeChild.call(container, child) + if (container.childElementCount === 0) portal.removeChild(root) + return child + } - useEffect(() => { - if (findMountingShadowRef && style.textContent !== css) style.textContent = css - }, [style, css, findMountingShadowRef]) + return { container, root } + }) + useEffect( + () => () => { + setTimeout(() => { + root.remove() + signal.current.abort() + }, 2000) + }, + [], + ) - return children as any + return renderer(container) } export function createShadowRootForwardedComponent< @@ -129,22 +111,7 @@ function PortalShadowRoot(): Element { return mountingPoint } -function bind(f: Function, thisArg: unknown, hook: Function) { - return (...args: any) => { - try { - return f.apply(thisArg, args) - } finally { - hook() - } - } -} - -function useUpdate() { - const [, _update] = useState(0) - return () => _update((i) => i + 1) -} - -function useSideEffectRef(f: () => T): T { +function useRefInit(f: () => T): T { const ref = useRef(undefined!) if (!ref.current) ref.current = f() return ref.current diff --git a/packages/theme/src/ShadowRoot/ShadowRootIsolation.tsx b/packages/theme/src/ShadowRoot/ShadowRootIsolation.tsx new file mode 100644 index 000000000000..049614304c9c --- /dev/null +++ b/packages/theme/src/ShadowRoot/ShadowRootIsolation.tsx @@ -0,0 +1,35 @@ +import { useState, useRef, useLayoutEffect } from 'react' +import { createPortal } from 'react-dom' +import { ShadowRootStyleProvider } from './ShadowRootStyleProvider' + +/** + * Render it's children inside a ShadowRoot to provide style isolation. + */ +export function ShadowRootIsolation({ + children, + ...props +}: React.DetailedHTMLProps, HTMLSpanElement>) { + const [dom, setDOM] = useState() + + const container = useRef() + if (!container.current) { + container.current = document.createElement('div') + } + useLayoutEffect(() => { + if (!dom) return + if (dom.shadowRoot) return + + const shadow = dom.attachShadow({ mode: 'open' }) + shadow.appendChild(container.current!) + }, [dom]) + + if (!dom?.shadowRoot) return (x !== dom ? setDOM(x) : undefined)} /> + + return ( + + + {createPortal(children, container.current)} + + + ) +} diff --git a/packages/theme/src/ShadowRoot/ShadowRootStyleProvider.tsx b/packages/theme/src/ShadowRoot/ShadowRootStyleProvider.tsx index 76192a8b6738..1d72c064f43f 100644 --- a/packages/theme/src/ShadowRoot/ShadowRootStyleProvider.tsx +++ b/packages/theme/src/ShadowRoot/ShadowRootStyleProvider.tsx @@ -1,118 +1,51 @@ +import createEmotionCache, { type EmotionCache } from '@emotion/cache' import { CacheProvider as EmotionCacheProvider } from '@emotion/react' -import createEmotionCache, { EmotionCache } from '@emotion/cache' -import { useMemo } from 'react' -import { useSubscription } from 'use-subscription' import { TssCacheProvider } from 'tss-react' +import { StyleSheet } from './ShadowRootStyleSheet' +import { StyleSheetsContext } from './Contexts' -class Informative { - private callback = new Set<() => void>() - addListener(cb: () => void) { - this.callback.add(cb) - return () => void this.callback.delete(cb) - } - private pendingInform = false - inform() { - if (this.pendingInform) return - requestAnimationFrame(() => { - this.pendingInform = false - this.callback.forEach((x) => x()) - }) - } -} -class EmotionInformativeSheetsRegistry { - reg = new Informative() - constructor(public cache: EmotionCache) { - const orig = cache.sheet.insert - cache.sheet.insert = (...args) => { - const r = orig.call(cache.sheet, ...args) - this.reg.inform() - return r - } - } - toString() { - return this.cache.sheet.tags.map((x) => x.innerHTML).join('\n') - } -} - -const emotionRegistryMap = new WeakMap() -const emotion2RegistryMap = new WeakMap() -function createSubscription(registry: undefined | EmotionInformativeSheetsRegistry) { - return { - getCurrentValue: () => registry?.toString(), - subscribe: (callback: () => void) => registry?.reg.addListener(callback) ?? (() => 0), - } -} - -/** - * Return all CSS created by the emotion instance in the current ShadowRoot. - * - * This is used to keep the CSS correct when the rendering is crossing multiple ShadowRoot (e.g. a Modal, Dialog or other things need rendered by React Portal) - * - * @param ref DOM reference - * @returns CSS string - */ -export function useCurrentShadowRootStyles(ref: Node | null): string { - const emotionSubscription = useMemo(() => { - const root = ref?.getRootNode() as ShadowRoot - const registry = emotionRegistryMap.get(root) - return createSubscription(registry) - }, [ref]) - const emotion2Subscription = useMemo(() => { - const root = ref?.getRootNode() as ShadowRoot - const registry = emotion2RegistryMap.get(root) - return createSubscription(registry) - }, [ref]) - return [useSubscription(emotionSubscription), useSubscription(emotion2Subscription)].filter(Boolean).join('\n') -} - -const initOnceMap = new WeakMap() -function initOnce(keyBy: ShadowRoot, init: () => T): T { - if (initOnceMap.has(keyBy)) return initOnceMap.get(keyBy) as T - const val = init() - initOnceMap.set(keyBy, val) - return val -} -function createElement(key: keyof HTMLElementTagNameMap, kind: string) { - const e = document.createElement(key) - e.setAttribute('data-' + kind, 'true') - return e -} /** @internal */ export interface ShadowRootStyleProviderProps extends React.PropsWithChildren<{}> { shadow: ShadowRoot } -/** @internal */ +/** + * @internal + * This component provide the modified version of tss-react and emotion context, + * therefore styles within this component can render correctly in ShadowRoot. + * + * This component is used to render inside a bare ShadowRoot. + * If you need a nested ShadowRoot render, use ShadowRootIsolation. + */ export function ShadowRootStyleProvider(props: ShadowRootStyleProviderProps) { const { shadow, children } = props - const { muiEmotionCache, tssEmotionCache } = initOnce(shadow, () => init(props)) + const [mui, tss, sheets] = getShadowRootEmotionCache(shadow) return ( - - {children} - + + + {children} + + ) } -function init({ shadow }: ShadowRootStyleProviderProps) { - const head = shadow.appendChild(createElement('head', 'css-container')) - // #region Emotion - const MuiInsertionPoint = head.appendChild(createElement('div', 'mui-area')) - const TSSInsertionPoint = head.appendChild(createElement('div', 'tss-area')) +const styleSheetMap = new WeakMap() + +function getShadowRootEmotionCache(shadow: ShadowRoot) { + if (styleSheetMap.has(shadow)) return styleSheetMap.get(shadow)! + // emotion doesn't allow numbers appears in the key - const instanceID = Math.random().toString(36).slice(2).replace(/\d/g, 'x') - const muiEmotionCache = createEmotionCache({ - container: MuiInsertionPoint, - key: 'mui-' + instanceID, - speedy: false, - // TODO: support speedy mode which use insertRule - // https://github.com/emotion-js/emotion/blob/master/packages/sheet/src/index.js - }) - const tssEmotionCache = createEmotionCache({ - container: TSSInsertionPoint, - key: 'tss-' + instanceID, - speedy: false, - }) - emotionRegistryMap.set(shadow, new EmotionInformativeSheetsRegistry(muiEmotionCache)) - emotion2RegistryMap.set(shadow, new EmotionInformativeSheetsRegistry(tssEmotionCache)) - // #endregion - return { muiEmotionCache, tssEmotionCache } + const instanceID = Math.random().toString(36).slice(2).replace(/\d/g, 'x').slice(0, 4) + const keyA = 'mui-' + instanceID + const keyB = 'tss-' + instanceID + + const muiEmotionCache = createEmotionCache({ key: keyA }) + const muiStyleSheet = new StyleSheet(keyA, shadow) + muiEmotionCache.sheet = muiStyleSheet + + const tssEmotionCache = createEmotionCache({ key: keyB }) + const tssStyleSheet = new StyleSheet(keyB, shadow) + tssEmotionCache.sheet = tssStyleSheet + + styleSheetMap.set(shadow, [muiEmotionCache, tssEmotionCache, [muiStyleSheet, tssStyleSheet]]) + return styleSheetMap.get(shadow)! } diff --git a/packages/theme/src/ShadowRoot/ShadowRootStyleSheet.ts b/packages/theme/src/ShadowRoot/ShadowRootStyleSheet.ts new file mode 100644 index 000000000000..704158131ff6 --- /dev/null +++ b/packages/theme/src/ShadowRoot/ShadowRootStyleSheet.ts @@ -0,0 +1,177 @@ +const shadowHeadMap = new WeakMap() +const constructableStyleSheetEnabled = true + +// There are 2 rendering mode of this ShadowRootStyleSheet. +// 1. If the host supports ConstructableStyleSheet proposal: +// It is the fastest but only Chrome supports it. +// 2. style tags that being synchronize between all ShadowRoot. +// See https://github.com/emotion-js/emotion/blob/main/packages/sheet/src/index.js +// Note: We cannot use .sheet.insertRule (so called "speedy mode") because in our app, +// the host of a ShadowRoot might be detached from the DOM and reattach to another place, +// when a