Shadow DOM内にReactを描画するためのコンポーネント

以前作成したこちらの記事[Bootstrap.cssを特定の領域内部のみに適用し、その中にReactのコンポーネントを描画する方法(ShadowDOMを利用)](https://qiita.com/murasuke/items/7d6c0e26e05b007af56f)
で、Shadow DOM内にReactを描画することはできましたが、いくつか問題がありました

1. CSSの継承プロパティが外部のDOMからshadow DOMへ継承されてしまい正しく表示されない場合がある
2. モーダルダイアログを表示すると、Bootstrapのスタイルが適用されない

1.については、[shadow DOMでもCSSのスコープを分離できない場合がある](https://qiita.com/murasuke/items/b9cdfdba88051fbf9471)に原因と対策を書きました

2.については、モーダルダイアログを表示する際、コンポーネントライブラリ側で[createPortal](https://ja.react.dev/reference/react-dom/createPortal#rendering-to-a-different-part-of-the-dom)を呼び出します。その結果document.bodyにDOMが追加される(shadow DOMの外に出てしまう)ため、スタイルが適用されなくなっていました

上記を考慮したコンポーネント&lt;IsolatedScope&gt;を作成しました

## 概要
* Shadow DOMを利用して、スタイルの干渉を防ぎながらReactコンポーネントをレンダリングする
* 外部CSSを適用可能 (styleSheetURLs プロパティ)
* Portalコンテナを提供し、ModalなどのUIコンポーネントの配置を容易にする (onPortalContainer)

### DOMの様子

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276579/e5f509ed-4c09-404b-824a-824e3b8c64ec.png)

* shadow DOM内に`bootstrap`が読み込まれており、モーダルダイアログも`shadow-root`配下に作成されているため、スタイルが正しく適用されるようになっています

## 利用方法

* &lt;IsolatedScope&gt;で囲んだ箇所が、shadow DOMでラップされます
* スタイルが外部と隔離されます。shadow DOM内部で適用するCSSのURLを渡します(オプション)
* モーダルダイアログを利用する場合、shadow DOM内部に作成したportalをコンテナとして利用する必要があります。コールバック関数でportalを受け取り、必要な個所で利用します

```jsx:main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import IsolatedScope from './IsolatedScope.tsx';
import './index.css';
import App from './App.tsx';

declare global {
  interface Window {
    portal_container: HTMLElement;
  }
}

// bootstrapのURL(shadow DOM内部に適用する)
const styleSheetURLs = [
  'https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css',
];

const root = document.getElementById('root');
createRoot(root!).render(
  <StrictMode>
    <IsolatedScope
      styleSheetURLs={styleSheetURLs}
      onPortalContainer={(container) => {
        // モーダルダイアログ表示用portalを保存
        window['portal_container'] = container;
      }}
    >
      <App />
    </IsolatedScope>
  </StrictMode>
);

```

* ダイアログ(&lt;Modal&gt;)の`container`に、portalを渡すことでダイアログが正しく表示されるようになります

```jsx:app.tsx
import { useState } from 'react';
import Button from 'react-bootstrap/Button';
import Modal from 'react-bootstrap/Modal';

function App() {
  const [show, setShow] = useState(false);

  const handleClose = () => setShow(false);
  const handleShow = () => setShow(true);

  return (
    <>
      <Button variant="primary" onClick={handleShow}>
        ダイアログを表示する
      </Button>
      <Modal
        container={window['portal_container']}
        show={show}
        onHide={handleClose}
      >
        <Modal.Header>
          <Modal.Title>ダイアログタイトル</Modal.Title>
        </Modal.Header>
        <Modal.Body>ダイアログコンテンツ</Modal.Body>
        <Modal.Footer>
          <Button variant="primary" onClick={handleClose}>
            閉じる
          </Button>
        </Modal.Footer>
      </Modal>
    </>
  );
}

export default App;

```

![image-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276579/088be3b0-9ce1-4d9f-85b7-a6a10534e005.png)

※`container`未指定の場合、document.body直下にモーダルダイアログが追加されるため正しく表示されません



![image-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276579/84e072f3-1f8b-4ebd-8063-04764da1deb1.png)


## &lt;IsolatedScope&gt;コンポーネントソース

 Shadow DOM内にReactを描画するためのコンポーネント

```jsx
/**
 * Shadow DOM内にReactを描画するためのコンポーネント
 */
import React, { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom/client';

type IsolatedScopeProps = {
  children: React.ReactNode;
  styleSheetURLs?: string[];
  onPortalContainer?: (container: HTMLElement) => void;
};

/**
 * Shadow DOM内にReactコンポーネントをレンダリングするコンポーネント
 * ・外部のスタイルシートの影響を受けない独立したスコープ (Shadow DOM) を提供します
 * ・`:host { all: initial; }` を適用することで、継承されたスタイルをリセットし、スタイルの衝突を防ぎます
 * ・`styleSheets` プロパティを通じて、Shadow DOM 内部に適用するスタイルシートを制御できます
 * ・Portalを利用するコンポーネント(ダイアログなど)をShadow DOM内に配置するためのコンテナ `portal-container`)を提供します
 *  これにより、ダイアログがShadow DOMのスタイルスコープ内で適切にスタイルされます
 *
 * 使用例:
 * ```tsx
 * <IsolatedScope styleSheets={['/bundle-react.css', '/other.css']}>
 *   <Button>Button Component</Button>
 * </IsolatedScope>
 * ```
 * @param children  レンダリングするReact子要素
 * @param styleSheets Shadow DOM内部に適用するスタイルシートURLの配列 (オプション)
 * @param onPortalContainer Portalを取得するためのコールバック関数 (オプション)
 *   このコールバック関数が`portal-container`要素を引数として呼び出されます
 *   ダイアログを表示する際のcontainerとしてコールバック引数を利用してください
 */
const IsolatedScope: React.FC<IsolatedScopeProps> = ({
  children,
  styleSheetURLs,
  onPortalContainer,
}) => {
  const shadowHostRef = useRef<HTMLDivElement | null>(null);
  const rootRef = useRef<ReactDOM.Root | null>(null);

  useEffect(() => {
    if (shadowHostRef.current) {
      // Shadow DOMが存在しない場合に作成 (初回レンダー時のみ実行)
      const shadowRoot =
        shadowHostRef.current.shadowRoot ||
        shadowHostRef.current.attachShadow({ mode: 'open' });

      // Shadow DOM 内のスタイルを初期化 (継承スタイルを打ち消し、スコープを分離)
      // :host はCSSの擬似クラスで、shadow DOMのルートを表す
      const sheet = new CSSStyleSheet();
      sheet.replaceSync(`
        :host {
          all: initial;
        }`);
      shadowRoot.adoptedStyleSheets = [sheet];

      if (!rootRef.current) {
        // 初回のみ実行(エラー回避)
        rootRef.current = ReactDOM.createRoot(shadowRoot);
      }

      // Shadow DOM 内に React コンポーネントをレンダリング
      rootRef.current.render(
        <div id="shadow-root">
          {styleSheetURLs?.map((url) => (
            <link rel="stylesheet" key={url} href={url}></link>
          ))}
          <div id="shadow-component-container">{children}</div>
          <div
            id="shadow-portal-container"
            ref={(portal) => {
              // マウントされたタイミングでコールバックを行う
              if (portal && onPortalContainer) {
                onPortalContainer(portal);
              }
            }}
          ></div>
        </div>
      );
    }
  }, [children, styleSheetURLs, onPortalContainer]);

  return <div id="isolated-scope-host" ref={shadowHostRef} />;
};

export default IsolatedScope;

```

### 各部分の解説

#### 1. Props の定義
```jsx
type IsolatedScopeProps = {
  children: React.ReactNode;
  styleSheetURLs?: string[];
  onPortalContainer?: (container: HTMLElement) => void;
};

```
* children: Shadow DOM 内にレンダリングする React 要素
* styleSheetURLs:Shadow DOM内部に適用するスタイルシートURLの配列 (オプション)
* onPortalContainer: Portalを取得するためのコールバック関数 (オプション)

#### 2. Shadow DOM の作成

```jsx
useEffect(() => {
  if (shadowHostRef.current) {
    const shadowRoot =
      shadowHostRef.current.shadowRoot ||
      shadowHostRef.current.attachShadow({ mode: 'open' });

    const sheet = new CSSStyleSheet();
    sheet.replaceSync(`
      :host {
        all: initial;
      }`);
    shadowRoot.adoptedStyleSheets = [sheet];

```

* shadowHostRef.current.attachShadow({ mode: 'open' })
  * shadowRoot を作成（すでに存在すればそれを再利用）
* CSSStyleSheet を適用
  * :host { all: initial; } によって、外部のスタイルをリセット
  * shadowRoot.adoptedStyleSheets = [sheet] で Shadow DOM に適用


#### 3. React のレンダリング

```jsx
if (!rootRef.current) {
  rootRef.current = ReactDOM.createRoot(shadowRoot);
}
```
* rootRef に ReactDOM.createRoot() を設定（初回のみ実行）

```jsx
rootRef.current.render(
  <div id="shadow-root">
    {styleSheetURLs?.map((url) => (
      <link rel="stylesheet" key={url} href={url}></link>
    ))}
    <div id="shadow-component-container">{children}</div>
    <div
      id="shadow-portal-container"
      ref={(portal) => {
        if (portal && onPortalContainer) {
          onPortalContainer(portal);
        }
      }}
    ></div>
  </div>
);
```

* `styleSheetURLs`で指定されたCSSのURLをlinkタグで適用(`#shadow-component-container`, `#shadow-portal-container`両方に適用される)
* childrenを`#shadow-component-container` 内にレンダリング
* `#shadow-portal-container` 作成時、refコールバックを利用することで(該当DOM要素がマウントされたタイミングで)`portal`を呼び出し元へ渡す