From 6c9f4de3e5284d9645441e8d0305658a430805df Mon Sep 17 00:00:00 2001 From: Jack Works Date: Thu, 3 Mar 2022 15:47:00 +0800 Subject: [PATCH 01/21] refactor: remove a private export from theme package --- packages/theme/src/ShadowRoot/index.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/theme/src/ShadowRoot/index.ts b/packages/theme/src/ShadowRoot/index.ts index 4ba0a5f243d9..986cc29a58ff 100644 --- a/packages/theme/src/ShadowRoot/index.ts +++ b/packages/theme/src/ShadowRoot/index.ts @@ -1,9 +1,8 @@ -export { useCurrentShadowRootStyles } from './ShadowRootStyleProvider' -export { createReactRootShadowedPartial } from './createReactRootShadowed' -export type { - CreateRenderInShadowRootConfig, - RenderInShadowRootConfig, - ReactRootShadowed, +export { + createReactRootShadowedPartial, + type CreateRenderInShadowRootConfig, + type RenderInShadowRootConfig, + type ReactRootShadowed, } from './createReactRootShadowed' export { usePortalShadowRoot, From bac9aea4a407a84a0dc6fc40ab59b68817bc045e Mon Sep 17 00:00:00 2001 From: Jack Works Date: Fri, 4 Mar 2022 19:14:19 +0800 Subject: [PATCH 02/21] chore: wip --- packages/theme/src/ShadowRoot/Portal.tsx | 8 +- .../ShadowRoot/ShadowRootStyleProvider.tsx | 104 +++++----------- .../ShadowRoot/createReactRootShadowed.tsx | 2 +- .../src/ShadowRootNext/EmotionProvider.tsx | 47 +++++++ .../src/ShadowRootNext/IsolationProvider.tsx | 26 ++++ .../ShadowRootNext/ShadowRootStyleSheet.ts | 117 ++++++++++++++++++ 6 files changed, 224 insertions(+), 80 deletions(-) create mode 100644 packages/theme/src/ShadowRootNext/EmotionProvider.tsx create mode 100644 packages/theme/src/ShadowRootNext/IsolationProvider.tsx create mode 100644 packages/theme/src/ShadowRootNext/ShadowRootStyleSheet.ts diff --git a/packages/theme/src/ShadowRoot/Portal.tsx b/packages/theme/src/ShadowRoot/Portal.tsx index 9e88a0845eae..45aec3a29b2d 100644 --- a/packages/theme/src/ShadowRoot/Portal.tsx +++ b/packages/theme/src/ShadowRoot/Portal.tsx @@ -1,5 +1,5 @@ import { useRef, useEffect, forwardRef, useState, createContext, useContext } from 'react' -import { useCurrentShadowRootStyles } from './ShadowRootStyleProvider' +import { useAddSyncToStyleSheet } from './ShadowRootStyleProvider' import type { PopperProps } from '@mui/material' /** @@ -79,7 +79,7 @@ type IsolatedRenderProps = React.PropsWithChildren<{ }> const IsolatedRender = ({ container, root, style, children, findMountingShadowRef }: IsolatedRenderProps) => { const update = useUpdate() - const css = useCurrentShadowRootStyles(findMountingShadowRef) + useAddSyncToStyleSheet(findMountingShadowRef) const containerInUse = container.children.length !== 0 useEffect(() => { @@ -94,10 +94,6 @@ const IsolatedRender = ({ container, root, style, children, findMountingShadowRe shadow.appendChild(root) }, [containerInUse, root]) - useEffect(() => { - if (findMountingShadowRef && style.textContent !== css) style.textContent = css - }, [style, css, findMountingShadowRef]) - return children as any } diff --git a/packages/theme/src/ShadowRoot/ShadowRootStyleProvider.tsx b/packages/theme/src/ShadowRoot/ShadowRootStyleProvider.tsx index 76192a8b6738..749b79a7895b 100644 --- a/packages/theme/src/ShadowRoot/ShadowRootStyleProvider.tsx +++ b/packages/theme/src/ShadowRoot/ShadowRootStyleProvider.tsx @@ -1,48 +1,10 @@ import { CacheProvider as EmotionCacheProvider } from '@emotion/react' -import createEmotionCache, { EmotionCache } from '@emotion/cache' -import { useMemo } from 'react' -import { useSubscription } from 'use-subscription' +import createEmotionCache from '@emotion/cache' import { TssCacheProvider } from 'tss-react' +import { StyleSheet } from '../ShadowRootNext/ShadowRootStyleSheet' +import { useContext, createContext } from 'react' -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), - } -} - +const SheetContext = createContext(null!) /** * Return all CSS created by the emotion instance in the current ShadowRoot. * @@ -51,18 +13,16 @@ function createSubscription(registry: undefined | EmotionInformativeSheetsRegist * @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') +export function useAddSyncToStyleSheet(ref: Node | null) { + const [emotion, tss] = useContext(SheetContext) + if (!ref) return + const shadow = ref.getRootNode() as ShadowRoot + if (!shadow) return + const head = shadow.querySelector('head') || shadow.appendChild(document.createElement('head')) + const muiInsertionPoint = head.querySelector('[data-mui]') || head.appendChild(createElement('div', 'mui')) + const tssInsertionPoint = head.querySelector('[data-tss]') || head.appendChild(createElement('div', 'tss')) + emotion.addContainer(muiInsertionPoint) + tss.addContainer(tssInsertionPoint) } const initOnceMap = new WeakMap() @@ -84,11 +44,13 @@ export interface ShadowRootStyleProviderProps extends React.PropsWithChildren<{} /** @internal */ export function ShadowRootStyleProvider(props: ShadowRootStyleProviderProps) { const { shadow, children } = props - const { muiEmotionCache, tssEmotionCache } = initOnce(shadow, () => init(props)) + const { muiEmotionCache, tssEmotionCache, sheets } = initOnce(shadow, () => init(props)) return ( - - {children} - + + + {children} + + ) } function init({ shadow }: ShadowRootStyleProviderProps) { @@ -99,20 +61,16 @@ function init({ shadow }: ShadowRootStyleProviderProps) { const TSSInsertionPoint = head.appendChild(createElement('div', 'tss-area')) // 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)) + + const muiEmotionCache = createEmotionCache({ key: 'x' }) + const muiStyleSheet = new StyleSheet({ key: 'mui-' + instanceID }) + muiStyleSheet.addContainer(MuiInsertionPoint) + muiEmotionCache.sheet = muiStyleSheet + + const tssEmotionCache = createEmotionCache({ key: 'x' }) + const tssStyleSheet = new StyleSheet({ key: 'tss-' + instanceID }) + tssStyleSheet.addContainer(TSSInsertionPoint) + tssEmotionCache.sheet = tssStyleSheet // #endregion - return { muiEmotionCache, tssEmotionCache } + return { muiEmotionCache, tssEmotionCache, sheets: [muiStyleSheet, tssStyleSheet] as const } } diff --git a/packages/theme/src/ShadowRoot/createReactRootShadowed.tsx b/packages/theme/src/ShadowRoot/createReactRootShadowed.tsx index cab6735650ee..4c2dc5d2ea53 100644 --- a/packages/theme/src/ShadowRoot/createReactRootShadowed.tsx +++ b/packages/theme/src/ShadowRoot/createReactRootShadowed.tsx @@ -6,7 +6,7 @@ import { ShadowRootStyleProvider } from './ShadowRootStyleProvider' export interface RenderInShadowRootConfig { /** Root tag. @default "main" */ tag?: keyof HTMLElementTagNameMap - /** Allow to render multiple React root into a same ShadowRoot */ + /** Used to distinguish multiple React root within a same ShadowRoot */ key?: string /** The AbortSignal to stop the render */ signal?: AbortSignal diff --git a/packages/theme/src/ShadowRootNext/EmotionProvider.tsx b/packages/theme/src/ShadowRootNext/EmotionProvider.tsx new file mode 100644 index 000000000000..4ac08971d03f --- /dev/null +++ b/packages/theme/src/ShadowRootNext/EmotionProvider.tsx @@ -0,0 +1,47 @@ +import { createContext, useContext } from 'react' +import createEmotionCache, { type EmotionCache } from '@emotion/cache' + +/** @internal */ +export const useEmotionProvider: (option: useEmotionProviderOptions) => useEmotionProviderReturns = + typeof document === 'object' && 'ado' in document + ? useEmotionProvider_ConstructableStyleSheet + : useEmotionProvider_Clone + +/** @internal */ +export const SyncStyleSheetToShadowRootContext = createContext<(ref: Node | null) => () => void>(() => { + throw new Error('No ShadowRootIsolationProvider is provided.') +}) + +/** + * Start to sync StyleSheet on a Node with the current scope. Return a function to stop syncing. + */ +export function useSyncStyleSheetToShadowRoot(): (ref: Node | null) => () => void { + return useContext(SyncStyleSheetToShadowRootContext) +} + +/** @internal */ +export interface useEmotionProviderOptions {} +/** @internal */ +export interface useEmotionProviderReturns { + muiEmotionCache: EmotionCache + tssEmotionCache: EmotionCache +} + +// #region Clone style +function useEmotionProvider_Clone(option: useEmotionProviderOptions): useEmotionProviderReturns { + const muiEmotionCache = createEmotionCache({ key: 'mui' }) + muiEmotionCache + return { + muiEmotionCache, + tssEmotionCache: createEmotionCache({ key: 'tss' }), + } +} +// #endregion + +// #region Share style +function useEmotionProvider_ConstructableStyleSheet(option: useEmotionProviderOptions): useEmotionProviderReturns { + return { + muiEmotionCache: createEmotionCache({ key: 'mui' }), + tssEmotionCache: createEmotionCache({ key: 'tss' }), + } +} diff --git a/packages/theme/src/ShadowRootNext/IsolationProvider.tsx b/packages/theme/src/ShadowRootNext/IsolationProvider.tsx new file mode 100644 index 000000000000..158070d2cd96 --- /dev/null +++ b/packages/theme/src/ShadowRootNext/IsolationProvider.tsx @@ -0,0 +1,26 @@ +import { createContext, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' + +const Context = createContext(null) + +export interface ShadowRootIsolationProviderProps extends React.PropsWithChildren<{}> {} +/** + * This component render it's children inside a ShadowRoot + * and fixes the ShadowRoot support for emotion + */ +export function ShadowRootIsolationProvider(props: ShadowRootIsolationProviderProps) { + 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]) + + return
{createPortal(props.children, container.current)}
+} diff --git a/packages/theme/src/ShadowRootNext/ShadowRootStyleSheet.ts b/packages/theme/src/ShadowRootNext/ShadowRootStyleSheet.ts new file mode 100644 index 000000000000..51eed9f71418 --- /dev/null +++ b/packages/theme/src/ShadowRootNext/ShadowRootStyleSheet.ts @@ -0,0 +1,117 @@ +/* https://github.com/emotion-js/emotion/blob/main/packages/sheet/src/index.js */ + +export type Options = { + key: string +} +export class StyleSheet { + tags: any = [] + container = document.createElement('div') + private ctr = 0 + private syncContainers = new Map() + key: string + private _alreadyInsertedOrderInsensitiveRule = false + constructor(options: Options) { + // key is the value of the data-emotion attribute, it's used to identify different sheets + this.key = options.key + } + addContainer(container: Element) { + const first = this.syncContainers.entries().next() + if (first.done) { + this.syncContainers.set(container, []) + } else { + const [, oldTags] = first.value + // append child + const frag = document.createDocumentFragment() + const tags = oldTags.map((x) => x.cloneNode() as HTMLStyleElement) + tags.forEach((x) => frag.appendChild(x)) + container.appendChild(frag) + // Note: we need to append it to DOM first, otherwise sheet is null. + // clone stylesheet + oldTags.forEach((oldTag, index) => { + const oldSheet = oldTag.sheet! + const newSheet = tags[index].sheet! + cloneSheet(oldSheet, newSheet) + }) + } + } + + private _insertTag = () => { + for (const [container, tags] of this.syncContainers) { + const child = createStyleElement({ key: this.key }) + tags.push(child) + container.appendChild(child) + } + } + + hydrate(_nodes: HTMLStyleElement[]) { + throw new Error('Does not support SSR.') + } + + insert(rule: string) { + if (this.ctr % 65000 === 0) { + this._insertTag() + } + + { + const isImportRule = rule.charCodeAt(0) === 64 && rule.charCodeAt(1) === 105 + + if (isImportRule && this._alreadyInsertedOrderInsensitiveRule) { + // this would only cause problem in speedy mode + // but we don't want enabling speedy to affect the observable behavior + // so we report this error at all times + console.error( + "You're attempting to insert the following rule:\n" + + rule + + '\n\n`@import` rules must be before all other types of rules in a stylesheet but other rules have already been inserted. Please ensure that `@import` rules are before all other rules.', + ) + } + + this._alreadyInsertedOrderInsensitiveRule = this._alreadyInsertedOrderInsensitiveRule || !isImportRule + } + + for (const tags of this.syncContainers.values()) { + const tag = tags.at(-1)! + const sheet = tag.sheet! + try { + // this is the ultrafast version, works across browsers + // the big drawback is that the css won't be editable in devtools + sheet.insertRule(rule, sheet.cssRules.length) + } catch (error) { + if ( + !/:(-moz-placeholder|-moz-focus-inner|-moz-focusring|-ms-input-placeholder|-moz-read-write|-moz-read-only|-ms-clear){/.test( + rule, + ) + ) { + console.error(`There was a problem inserting the following rule: "${rule}"`, error) + } + } + } + this.ctr += 1 + } + + flush() { + for (const tags of this.syncContainers.values()) { + tags.forEach((tag) => tag.parentNode?.removeChild(tag)) + tags.length = 0 + } + this.ctr = 0 + this._alreadyInsertedOrderInsensitiveRule = false + } +} + +function cloneSheet(old: CSSStyleSheet, newOne: CSSStyleSheet) { + for (const rule of old.cssRules) { + newOne.insertRule(rule.cssText, newOne.cssRules.length) + } +} + +function createStyleElement(options: { key: string; nonce?: string }): HTMLStyleElement { + const tag = document.createElement('style') + tag.dataset.emotion = options.key + if (options.nonce !== undefined) { + tag.setAttribute('nonce', options.nonce) + } + tag.appendChild(document.createTextNode('')) + tag.dataset.s = '' + return tag +} From 31aa5e7a0bf12d9f824baea3e9a36cb070acd844 Mon Sep 17 00:00:00 2001 From: Jack Works Date: Mon, 7 Mar 2022 12:00:06 +0800 Subject: [PATCH 03/21] fix: global styles --- .../src/ShadowRoot/CSSVariableInjector.tsx | 19 ++-------- .../ShadowRoot/ShadowRootStyleProvider.tsx | 12 +----- .../ShadowRootNext/ShadowRootStyleSheet.ts | 37 +++++++++++++------ packages/theme/src/constants.ts | 10 +++++ 4 files changed, 42 insertions(+), 36 deletions(-) diff --git a/packages/theme/src/ShadowRoot/CSSVariableInjector.tsx b/packages/theme/src/ShadowRoot/CSSVariableInjector.tsx index 05b0a73945c2..0a6a56b5c287 100644 --- a/packages/theme/src/ShadowRoot/CSSVariableInjector.tsx +++ b/packages/theme/src/ShadowRoot/CSSVariableInjector.tsx @@ -1,21 +1,10 @@ -import { applyMaskColorVars } from '../' -import { Theme, useTheme } from '@mui/material' -import { useLayoutEffect, useRef } from 'react' +import { GlobalStyles, Theme, useTheme } from '@mui/material' +import { useRef } from 'react' +import { CSSVariableInjectorCSS } from '../constants' 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 + return } diff --git a/packages/theme/src/ShadowRoot/ShadowRootStyleProvider.tsx b/packages/theme/src/ShadowRoot/ShadowRootStyleProvider.tsx index 749b79a7895b..540cf6f18136 100644 --- a/packages/theme/src/ShadowRoot/ShadowRootStyleProvider.tsx +++ b/packages/theme/src/ShadowRoot/ShadowRootStyleProvider.tsx @@ -54,23 +54,15 @@ export function ShadowRootStyleProvider(props: ShadowRootStyleProviderProps) { ) } 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')) // emotion doesn't allow numbers appears in the key const instanceID = Math.random().toString(36).slice(2).replace(/\d/g, 'x') const muiEmotionCache = createEmotionCache({ key: 'x' }) - const muiStyleSheet = new StyleSheet({ key: 'mui-' + instanceID }) - muiStyleSheet.addContainer(MuiInsertionPoint) + const muiStyleSheet = new StyleSheet('mui-' + instanceID, shadow) muiEmotionCache.sheet = muiStyleSheet const tssEmotionCache = createEmotionCache({ key: 'x' }) - const tssStyleSheet = new StyleSheet({ key: 'tss-' + instanceID }) - tssStyleSheet.addContainer(TSSInsertionPoint) + const tssStyleSheet = new StyleSheet('tss-' + instanceID, shadow) tssEmotionCache.sheet = tssStyleSheet - // #endregion return { muiEmotionCache, tssEmotionCache, sheets: [muiStyleSheet, tssStyleSheet] as const } } diff --git a/packages/theme/src/ShadowRootNext/ShadowRootStyleSheet.ts b/packages/theme/src/ShadowRootNext/ShadowRootStyleSheet.ts index 51eed9f71418..01ac1f151c89 100644 --- a/packages/theme/src/ShadowRootNext/ShadowRootStyleSheet.ts +++ b/packages/theme/src/ShadowRootNext/ShadowRootStyleSheet.ts @@ -1,20 +1,34 @@ /* https://github.com/emotion-js/emotion/blob/main/packages/sheet/src/index.js */ - -export type Options = { - key: string -} +const shadowHeadMap = new WeakMap() export class StyleSheet { + // Unlucky, emotion will create it's own StyleSheet and use isSpeedy, tags, keys and container for Global components. + isSpeedy = true tags: any = [] - container = document.createElement('div') + container: HTMLElement + private ctr = 0 - private syncContainers = new Map() - key: string + private shadowHead: HTMLDivElement + private syncContainers = new Map() private _alreadyInsertedOrderInsensitiveRule = false - constructor(options: Options) { - // key is the value of the data-emotion attribute, it's used to identify different sheets - this.key = options.key + constructor(public key: string, private containerShadow: ShadowRoot) { + if (!shadowHeadMap.has(containerShadow)) { + const div = document.createElement('div') + div.dataset.styleContainer = '' + shadowHeadMap.set(containerShadow, div) + containerShadow.insertBefore(div, containerShadow.firstChild) + } + const shadowHead = (this.shadowHead = shadowHeadMap.get(containerShadow)!) + + const global = (this.container = document.createElement('div')) + global.dataset.globalOf = key + shadowHead.insertBefore(global, shadowHead.firstChild) + + const local = document.createElement('div') + local.dataset.key = key + shadowHead.appendChild(local) + this.addContainer(local) } - addContainer(container: Element) { + addContainer(container: Element | ShadowRoot) { const first = this.syncContainers.entries().next() if (first.done) { this.syncContainers.set(container, []) @@ -72,6 +86,7 @@ export class StyleSheet { for (const tags of this.syncContainers.values()) { const tag = tags.at(-1)! const sheet = tag.sheet! + if (!sheet) console.warn('NO sheet is found. The ShadowRoot is not attached to the DOM tree.') try { // this is the ultrafast version, works across browsers // the big drawback is that the css won't be editable in devtools diff --git a/packages/theme/src/constants.ts b/packages/theme/src/constants.ts index 5d72c677745c..8d876c5645bc 100644 --- a/packages/theme/src/constants.ts +++ b/packages/theme/src/constants.ts @@ -160,6 +160,16 @@ export function useMaskColor() { return getMaskColor(useTheme()) } +export function CSSVariableInjectorCSS(scheme: PaletteMode) { + const ns: Record = scheme === 'light' ? LightColor : DarkColor + const result: Record = {} + for (const key in ns) { + // --mask-name: val; + result[`--mask-${kebabCase(key)}`] = ns[key] + result[`--mask-${kebabCase(key)}-fragment`] = getRGBFragment(ns, key) + } + return { ':root, :host': result } +} export function applyMaskColorVars(node: HTMLElement, scheme: PaletteMode) { const ns: Record = scheme === 'light' ? LightColor : DarkColor if (node === document.body) { From ff4d53d2abd67e56ee925ba75badeba8c628ffd5 Mon Sep 17 00:00:00 2001 From: Jack Works Date: Mon, 7 Mar 2022 15:10:46 +0800 Subject: [PATCH 04/21] fix: works again --- .../src/playground.ts | 2 +- .../ITO/SNSAdaptor/CompositionDialog.tsx | 6 +- .../defaults/inject/PageInspector.tsx | 11 +- .../defaults/inject/StartSetupGuide.tsx | 11 +- .../utils/shadow-root/renderInShadowRoot.tsx | 9 +- .../src/utils/createInjectHooksRenderer.tsx | 5 +- .../{ShadowRoot => }/CSSVariableInjector.tsx | 2 +- packages/theme/src/ShadowRoot/Contexts.ts | 7 + .../ShadowRoot/NestedShadowRootIsolation.tsx | 33 +++++ packages/theme/src/ShadowRoot/Portal.tsx | 95 +++---------- .../ShadowRoot/ShadowRootStyleProvider.tsx | 89 +++++------- .../src/ShadowRoot/ShadowRootStyleSheet.ts | 115 +++++++++++++++ .../ShadowRoot/createReactRootShadowed.tsx | 52 ++++--- packages/theme/src/ShadowRoot/index.ts | 6 +- .../src/ShadowRootNext/EmotionProvider.tsx | 47 ------- .../src/ShadowRootNext/IsolationProvider.tsx | 26 ---- .../ShadowRootNext/ShadowRootStyleSheet.ts | 132 ------------------ packages/theme/src/index.ts | 1 + 18 files changed, 259 insertions(+), 390 deletions(-) rename packages/theme/src/{ShadowRoot => }/CSSVariableInjector.tsx (88%) create mode 100644 packages/theme/src/ShadowRoot/Contexts.ts create mode 100644 packages/theme/src/ShadowRoot/NestedShadowRootIsolation.tsx create mode 100644 packages/theme/src/ShadowRoot/ShadowRootStyleSheet.ts delete mode 100644 packages/theme/src/ShadowRootNext/EmotionProvider.tsx delete mode 100644 packages/theme/src/ShadowRootNext/IsolationProvider.tsx delete mode 100644 packages/theme/src/ShadowRootNext/ShadowRootStyleSheet.ts 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..abd2986ddd20 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 { NestedShadowRootIsolation } from '@masknet/theme' type Inject = Plugin.InjectUI type Raw = Plugin.InjectUIRaw @@ -44,7 +45,9 @@ export function createInjectHooksRenderer ( - + + + )) return <>{all} diff --git a/packages/theme/src/ShadowRoot/CSSVariableInjector.tsx b/packages/theme/src/CSSVariableInjector.tsx similarity index 88% rename from packages/theme/src/ShadowRoot/CSSVariableInjector.tsx rename to packages/theme/src/CSSVariableInjector.tsx index 0a6a56b5c287..feee253efdd5 100644 --- a/packages/theme/src/ShadowRoot/CSSVariableInjector.tsx +++ b/packages/theme/src/CSSVariableInjector.tsx @@ -1,6 +1,6 @@ import { GlobalStyles, Theme, useTheme } from '@mui/material' import { useRef } from 'react' -import { CSSVariableInjectorCSS } from '../constants' +import { CSSVariableInjectorCSS } from './constants' export function CSSVariableInjector(props: React.PropsWithChildren<{ useTheme?: () => Theme }>) { const { current: useConsistentTheme } = useRef(props.useTheme || useTheme) 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/NestedShadowRootIsolation.tsx b/packages/theme/src/ShadowRoot/NestedShadowRootIsolation.tsx new file mode 100644 index 000000000000..25d1039160dc --- /dev/null +++ b/packages/theme/src/ShadowRoot/NestedShadowRootIsolation.tsx @@ -0,0 +1,33 @@ +import { useState, useContext, useRef, useLayoutEffect } from 'react' +import { createPortal } from 'react-dom' +import { StyleSheetsContext } from './Contexts' + +/** + * Render it's children inside a ShadowRoot to provide style isolation. + * + * It MUST under a component. + */ +export function NestedShadowRootIsolation(props: React.PropsWithChildren<{}>) { + const [dom, setDOM] = useState() + const sheets = useContext(StyleSheetsContext) + + 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!) + + sheets.map((x) => x.addContainer(shadow)) + }, [dom]) + + return ( +
+ {createPortal(props.children, container.current)} +
+ ) +} diff --git a/packages/theme/src/ShadowRoot/Portal.tsx b/packages/theme/src/ShadowRoot/Portal.tsx index 45aec3a29b2d..cb5dc5914f2f 100644 --- a/packages/theme/src/ShadowRoot/Portal.tsx +++ b/packages/theme/src/ShadowRoot/Portal.tsx @@ -1,25 +1,14 @@ -import { useRef, useEffect, forwardRef, useState, createContext, useContext } from 'react' -import { useAddSyncToStyleSheet } from './ShadowRootStyleProvider' +import { useRef, forwardRef, createContext, useContext } from 'react' import type { PopperProps } from '@mui/material' +import { 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,60 +30,27 @@ 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) { // 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) + const sheets = useContext(StyleSheetsContext) // 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 } = useRefInit(() => { + const portal = PortalShadowRoot() + const root = portal.appendChild(document.createElement('div')) + root.dataset.portalShadowRoot = '' + const shadow = root.attachShadow({ mode: 'open' }) + const container = shadow.appendChild(document.createElement('main')) + sheets.map((x) => x.addContainer(shadow)) + console.log(shadow, sheets) + return { container } }) - const { container } = doms - return ( - - findMountingShadowRef !== ref && setRef(ref)} /> - {renderer(container)} - - ) -} - -/* -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() - useAddSyncToStyleSheet(findMountingShadowRef) - const containerInUse = container.children.length !== 0 - - useEffect(() => { - container.appendChild = bind(container.appendChild, container, update) - container.removeChild = bind(container.removeChild, container, update) - }, []) - - useEffect(() => { - if (!containerInUse) return root.remove() - const shadow = PortalShadowRoot() - if (root.parentElement === shadow) return - shadow.appendChild(root) - }, [containerInUse, root]) - - return children as any + return renderer(container) } export function createShadowRootForwardedComponent< @@ -125,22 +81,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/ShadowRootStyleProvider.tsx b/packages/theme/src/ShadowRoot/ShadowRootStyleProvider.tsx index 540cf6f18136..10bf514b50c3 100644 --- a/packages/theme/src/ShadowRoot/ShadowRootStyleProvider.tsx +++ b/packages/theme/src/ShadowRoot/ShadowRootStyleProvider.tsx @@ -1,68 +1,53 @@ +import { useRef } from 'react' +import createEmotionCache, { type EmotionCache } from '@emotion/cache' import { CacheProvider as EmotionCacheProvider } from '@emotion/react' -import createEmotionCache from '@emotion/cache' import { TssCacheProvider } from 'tss-react' -import { StyleSheet } from '../ShadowRootNext/ShadowRootStyleSheet' -import { useContext, createContext } from 'react' +import { StyleSheet } from './ShadowRootStyleSheet' +import { StyleSheetsContext } from './Contexts' -const SheetContext = createContext(null!) -/** - * 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 useAddSyncToStyleSheet(ref: Node | null) { - const [emotion, tss] = useContext(SheetContext) - if (!ref) return - const shadow = ref.getRootNode() as ShadowRoot - if (!shadow) return - const head = shadow.querySelector('head') || shadow.appendChild(document.createElement('head')) - const muiInsertionPoint = head.querySelector('[data-mui]') || head.appendChild(createElement('div', 'mui')) - const tssInsertionPoint = head.querySelector('[data-tss]') || head.appendChild(createElement('div', 'tss')) - emotion.addContainer(muiInsertionPoint) - tss.addContainer(tssInsertionPoint) -} - -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, sheets } = initOnce(shadow, () => init(props)) + const [mui, tss, sheets] = useShadowRootEmotionCache(shadow) return ( - - - {children} + + + {children} - + ) } -function init({ shadow }: ShadowRootStyleProviderProps) { - // emotion doesn't allow numbers appears in the key - const instanceID = Math.random().toString(36).slice(2).replace(/\d/g, 'x') - const muiEmotionCache = createEmotionCache({ key: 'x' }) - const muiStyleSheet = new StyleSheet('mui-' + instanceID, shadow) - muiEmotionCache.sheet = muiStyleSheet +function useShadowRootEmotionCache(shadow: ShadowRoot) { + const ref = useRef() + + if (!ref.current) { + // emotion doesn't allow numbers appears in the key + const instanceID = Math.random().toString(36).slice(2).replace(/\d/g, 'x') + 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 + + ref.current = [muiEmotionCache, tssEmotionCache, [muiStyleSheet, tssStyleSheet]] + } - const tssEmotionCache = createEmotionCache({ key: 'x' }) - const tssStyleSheet = new StyleSheet('tss-' + instanceID, shadow) - tssEmotionCache.sheet = tssStyleSheet - return { muiEmotionCache, tssEmotionCache, sheets: [muiStyleSheet, tssStyleSheet] as const } + return ref.current } diff --git a/packages/theme/src/ShadowRoot/ShadowRootStyleSheet.ts b/packages/theme/src/ShadowRoot/ShadowRootStyleSheet.ts new file mode 100644 index 000000000000..f177d20fdd5f --- /dev/null +++ b/packages/theme/src/ShadowRoot/ShadowRootStyleSheet.ts @@ -0,0 +1,115 @@ +const shadowHeadMap = new WeakMap() +/* https://github.com/emotion-js/emotion/blob/main/packages/sheet/src/index.js */ + +// 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. +// 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