Closed39

Module Federation で tailwind の style 共有を考える

nbstshnbstsh

tailwind ベースの component を ModuleFederation で共有する際の css の取り扱いについてメモしていく。

nbstshnbstsh

rsbuild で host, remote の app を用意し試していく。

nbstshnbstsh

そもそも、css file を expose することは可能なのか?

nbstshnbstsh

試しに remote app から sample css を expose して host app で組み込んでみる。

expose sample.css

remote-app/src/sample.css
.remote-style {
    color: red;
    font-size: 20px;
    font-weight: bold;
}
remote-app/rsbuild.config.ts
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';

export default defineConfig({
  plugins: [pluginReact()],
  server: {
    port: 3001,
  },
  moduleFederation: {
    options: {
      name: 'remote',
      filename: 'remoteEntry.js',
      exposes: {
        './Button': './src/components/Button',
        './sample.css': './src/sample.css',
      },
    }
  },
});
nbstshnbstsh

import sample.css

host-app/rsbuild.config.ts
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';

console.log('NODE_ENV', process.env.NODE_ENV);
console.log('REMOTE_APP_URL', process.env.REMOTE_APP_URL);

export default defineConfig({
  plugins: [pluginReact()],
  server: {
    port: 3000,
  },
  moduleFederation: {
    options: {
      name: 'host',
      remotes: {
        remote: `remote@${process.env.REMOTE_APP_URL}/remoteEntry.js`,
      },
    }
  },
});

雑に import してみる

host-app/src/App.tsx
import('remote/sample.css');

const App = () => (
  <div>
    <h1>Host Application</h1>
    <div className="remote-style">
      Hello, I'm styled with remote css
    </div>
  </div>
);

export default App;

ちゃんと css 適用されてる↓

"http://localhost:3001/static/css/async/src_sample_css.css" に css が吐き出されてる。

remote module の css を import すると remote server に host された css に対する link tag が append されるっぽい。

nbstshnbstsh

ただこれだと、remote から css を取得するまでの間に style が適用されないので、css 取得完了まで fallback view 出したい。

host-app/src/App.tsx
import { useEffect, useState } from 'react';

const App = () => {
  const [cssLoaded, setCssLoaded] = useState(false);

  useEffect(() => {
    import('remote/sample.css')
      .then(() => setCssLoaded(true))
      .catch((error) => console.error('Failed to load remote CSS:', error));
  }, []);

  return (
    <div>
      <h1>Host Application</h1>
      {cssLoaded ? (
        <div className="remote-style">
          Hello, I'm styled with remote css
        </div>
      ) : (
        <div>Loading remote CSS...</div>
      )}
    </div>
  );
};

export default App;
nbstshnbstsh

Suspense 使うように refactor

host-app/src/App.tsx
import React, { lazy, Suspense } from 'react';

// Create a wrapper component that loads the CSS
const CSSWrapper = lazy(() => {
  return import('remote/sample.css').then(() => ({
    default: ({ children }: { children: React.ReactNode }) => <>{children}</>
  }));
});

const App = () => (
  <div>
    <h1>Host Application</h1>
    <Suspense fallback={<div>Loading styles...</div>}>
      <CSSWrapper>
        <div className="remote-style">
          Hello, I'm styled with remote css
        </div>
      </CSSWrapper>
    </Suspense>
  </div>
);

export default App;
nbstshnbstsh

tailwind の css を expoes するにはどうすればいい...?

remote app の src 内の raw css を expose できることはわかったが、tailwind のように PostCSS で生成される CSS はどうすればいい...?

@tailwind directive 使ってる entry css を expose してあげれば bundler が build した成果物が static asset として提供してくれるのか...?

とりあえず index.css を expose してみる。

remote-app/src/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;
remote-app/rsbuild.config.ts
export default defineConfig({  moduleFederation: {
    //...
    options: {
      name: 'remote',
      filename: 'remoteEntry.js',
      exposes: {
        './Button': './src/components/Button',
        './sample.css': './src/sample.css',
+        './index.css': './src/index.css',
      },
    }
  },
});
nbstshnbstsh

sample.css と同様に、index.css が static asset として localhost:3001 から serve されてるはずなので "http://localhost:3001/static/css/async/src_index_css.css" を確認。

tailwind 関連の class ちゃんと生成されている...!
今まで tailwind の class が生成されるフローを気にしたことなかったけど、冷静に考えると

  • css を load
  • @tailwind があったら bundler が PostCSS tailwind plugin 実行
  • tailwind plugin が config に従って project を scanning
  • 利用されている tailwid class が jit で生成される

みたいなフローになってるはずだから、生成された tailwind の css を expose したかったら @tailwind を記載した css file を expose してあげればいい、ってことになるわな。

nbstshnbstsh

@tailwind base で tailwind の preflight css が生成されるが、こちらは app shell (host-app) 側でセットされるはずなので、remote app から expose する css に含める必要はない。

https://tailwindcss.com/docs/preflight

つまり、以下の css を expose すればOK。

remote-app/src/tailwind-expose.css
@tailwind components;
@tailwind utilities;
remote-app/rsbuild.config.ts
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';

export default defineConfig({
  plugins: [pluginReact()],
  server: {
    port: 3001,
  },
  moduleFederation: {
    options: {
      name: 'remote',
      filename: 'remoteEntry.js',
      exposes: {
        './Button': './src/components/Button',
        './tailwind-expose.css': './src/tailwind-expose.css',
      },
    }
  },
});

これで、tailwind の生成された css を Module Federation で共有できる。

nbstshnbstsh

tailwind style conflict

css framework が生成する class が順序に依存していると Micro Frontend の文脈で競合が起きないように注意する必要がある。

tailwind は utility-first な css なので class の順序が変わろうと style の競合は起きないものと考えていたが、例外があった。sm, md みたいな responsive utility variants は生成される順序に依存するため競合が起きる。

nbstshnbstsh

競合が起きる例

host app で responsive

host app で responsive に色が変わる text 用意。

const RemoteTailwindExample: React.FC = () => {
  return (
    <div className="space-y-4">
      <div className="sm:text-red-500 md:text-blue-500">
        This text demonstrates Tailwind responsive classes
      </div>
    </div>
  );
};

md:text-blue-500 が適用される↓

remote app で responsive な component 用意

remote app で button を用意。sm:text-red-500 を利用する。

const Button: React.FC<ButtonProps> = ({ onClick, children }) => {
  return (
    <button
      onClick={onClick}
      className="sm:text-red-500"
    >
      {children}
    </button>
  );
};

この状態で生成される remote app の tailwind↓

host app で remote app から css import

host app で RemoteButton を生成された css とともに import する。

host-app/src/RemoteTailwindExample.tsx
import React, { lazy, Suspense } from 'react';

const RemoteButton = lazy(() => import('remote/Button'));

const RemoteTailwindWrapper = lazy(() => {
  return import('remote/tailwind-expose.css').then(() => ({
    default: ({ children }: { children: React.ReactNode }) => <>{children}</>
  }));
});

const RemoteTailwindExample: React.FC = () => {
  return (
    <div className="space-y-4">
      <div className="sm:text-red-500 md:text-blue-500">
        This text demonstrates Tailwind responsive classes
      </div>
      <Suspense fallback={<div>Loading button...</div>}>
        <RemoteTailwindWrapper>
          <RemoteButton>I'm a remote button with Tailwind CSS</RemoteButton>
        </RemoteTailwindWrapper>
      </Suspense>
    </div>
  );
};

export default RemoteTailwindExample;

本来なら md:text-blue-500 が適用された青くなるはずが、赤くなってしまう。
remote app から css を import した結果、 sm:text-red-500md:text-blue-500 の順序が入れ替わり、後ろに来た sm:text-red-500 が適用されてしまっている。

nbstshnbstsh

こちら参考になりそう

https://malcolmkee.com/blog/using-tailwindcss-with-module-federation/

  • PostCSS で生成される tailwind class に module ごとの prefix を利用することで競合を回避する。
  • className 指定箇所で runtime で prefix を付与する utility を噛ませる

https://github.com/marceloucker/postcss-prefixer

runtime で prefix 付与しなきゃいけないのが微妙...

tailwind 自体にも prefix での対応とそこまで大差ないな...tailwind config の prefix は既存のコード内の class name 全てに prefix を付与する必要がありメンテナンスがしんどいので使いたくはない。

nbstshnbstsh

Custom PostCSS plugin で scoping

remote app で全ての css を [data-mf=app-name] で wrap する PostCSS plugin を用意する↓
(CSS nesting 使わなくても PostCSS plugin 作れば対応できた)

postcss.config.cjs
const postcss = require('postcss');

const wrapWithDataSelector = (opts = {}) => {
  return {
    postcssPlugin: 'postcss-wrap-with-data-selector',
    Once(root) {
      // Create a new rule that wraps the entire content
      const wrapper = postcss.rule({
        selector: `[data-mf=${opts.name}]`
      });

      // Move all root nodes (CSS rules) into the wrapper rule
      wrapper.append(root.nodes);

      // Replace the root nodes with the wrapper rule
      root.removeAll();
      root.append(wrapper);
    }
  };
};

module.exports = {
  plugins: [
    require('tailwindcss'),
    wrapWithDataSelector({ name: 'remote' }),
  ],
};

生成された css↓

あとは、host app で remote app の css を利用する箇所で data-mf=remote を付与してあげればOK。

import React, { lazy, Suspense } from 'react';

const RemoteButton = lazy(() => import('remote/Button'));

const RemoteTailwindWrapper = lazy(() => {
  return import('remote/tailwind-expose.css').then(() => ({
+    default: ({ children }: { children: React.ReactNode }) => <div data-mf="remote">{children}</div>
  }));
});

const RemoteTailwindExample: React.FC = () => {
  return (
    <div className="space-y-4">
      <div className="sm:text-red-500 md:text-blue-500">
        This text demonstrates Tailwind responsive classes
      </div>
      <Suspense fallback={<div>Loading button...</div>}>
        <RemoteTailwindWrapper>
          <RemoteButton>I'm a remote button with Tailwind CSS</RemoteButton>
        </RemoteTailwindWrapper>
      </Suspense>
    </div>
  );
};

export default RemoteTailwindExample;

これで tailwind style conflict 解決↓


nbstshnbstsh

shadcn ui を利用した場合の問題点

shadcn ui では、以下の css が前提になる↓

globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 47.4% 11.2%;

    --muted: 210 40% 96.1%;
    --muted-foreground: 215.4 16.3% 46.9%;

    --popover: 0 0% 100%;
    --popover-foreground: 222.2 47.4% 11.2%;

    --border: 214.3 31.8% 91.4%;
    --input: 214.3 31.8% 91.4%;

    --card: 0 0% 100%;
    --card-foreground: 222.2 47.4% 11.2%;

    --primary: 222.2 47.4% 11.2%;
    --primary-foreground: 210 40% 98%;

    --secondary: 210 40% 96.1%;
    --secondary-foreground: 222.2 47.4% 11.2%;

    --accent: 210 40% 96.1%;
    --accent-foreground: 222.2 47.4% 11.2%;

    --destructive: 0 100% 50%;
    --destructive-foreground: 210 40% 98%;

    --ring: 215 20.2% 65.1%;

    --radius: 0.5rem;
  }

  .dark {
    --background: 224 71% 4%;
    --foreground: 213 31% 91%;

    --muted: 223 47% 11%;
    --muted-foreground: 215.4 16.3% 56.9%;

    --accent: 216 34% 17%;
    --accent-foreground: 210 40% 98%;

    --popover: 224 71% 4%;
    --popover-foreground: 215 20.2% 65.1%;

    --border: 216 34% 17%;
    --input: 216 34% 17%;

    --card: 224 71% 4%;
    --card-foreground: 213 31% 91%;

    --primary: 210 40% 98%;
    --primary-foreground: 222.2 47.4% 1.2%;

    --secondary: 222.2 47.4% 11.2%;
    --secondary-foreground: 210 40% 98%;

    --destructive: 0 63% 31%;
    --destructive-foreground: 210 40% 98%;

    --ring: 216 34% 17%;

    --radius: 0.5rem;
  }
}

@layer base {
  * {
    @apply border-border;
  }
  body {
    @apply bg-background text-foreground;
    font-feature-settings: 'rlig' 1, 'calt' 1;
  }
}

Module Federation の文脈で考えると、:root, body の指定は利用できない。その module の component が利用される scope のみで適用されるのがベスト。

また、module から expose される tailwind の style では、@tailwind base は含ませないので
@layer base の指定も利用できない。

nbstshnbstsh

@layer base 使えない問題対応

@tailwind base を指定しない状態で @layer base を利用すると以下のような SyntaxError が起きる。

- @tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
`@layer base` is used but no matching `@tailwind base` directive is present.

以下のように stylesheet の先頭で自前で @layer base; とし、 base layer を定義しても同様のエラーとなる。

+ @layer base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {

ただ、Module Federation で expose する tailwind 用 css は一つのみなので、@layer を使う必要はない。シンプルに file 内での css の順番を注意すればいいだけ。

@layer base を削除して、@tailwind components;, @tailwind utilities; は file 末尾に持って来ればOK。

tailwind-expose.css
:root {
  --background: 0 0% 100%;
  --foreground: 222.2 47.4% 11.2%;

  --muted: 210 40% 96.1%;
  --muted-foreground: 215.4 16.3% 46.9%;

  --popover: 0 0% 100%;
  --popover-foreground: 222.2 47.4% 11.2%;

  --border: 214.3 31.8% 91.4%;
  --input: 214.3 31.8% 91.4%;

  --card: 0 0% 100%;
  --card-foreground: 222.2 47.4% 11.2%;

  --primary: 222.2 47.4% 11.2%;
  --primary-foreground: 210 40% 98%;

  --secondary: 210 40% 96.1%;
  --secondary-foreground: 222.2 47.4% 11.2%;

  --accent: 210 40% 96.1%;
  --accent-foreground: 222.2 47.4% 11.2%;

  --destructive: 0 100% 50%;
  --destructive-foreground: 210 40% 98%;

  --ring: 215 20.2% 65.1%;

  --radius: 0.5rem;
}

.dark {
  --background: 224 71% 4%;
  --foreground: 213 31% 91%;

  --muted: 223 47% 11%;
  --muted-foreground: 215.4 16.3% 56.9%;

  --accent: 216 34% 17%;
  --accent-foreground: 210 40% 98%;

  --popover: 224 71% 4%;
  --popover-foreground: 215 20.2% 65.1%;

  --border: 216 34% 17%;
  --input: 216 34% 17%;

  --card: 224 71% 4%;
  --card-foreground: 213 31% 91%;

  --primary: 210 40% 98%;
  --primary-foreground: 222.2 47.4% 1.2%;

  --secondary: 222.2 47.4% 11.2%;
  --secondary-foreground: 210 40% 98%;

  --destructive: 0 63% 31%;
  --destructive-foreground: 210 40% 98%;

  --ring: 216 34% 17%;

  --radius: 0.5rem;
}

* {
  @apply border-border;
}

body {
  @apply bg-background text-foreground;
  font-feature-settings: 'rlig' 1, 'calt' 1;
}

@tailwind components;
@tailwind utilities;

Refactoring

現状 globals.css と tailwind-expose.css の両者で shadcn の前提となる styles を定義しちゃってるので重複を省くため共通化する。

別の css file を作成し @import する方向性でいく。

globals.css と tailwind-expose.css 共通化

tailwind-base.css

tailwind-base.css
@tailwind base;

@import は必ず file の最初に記載する必要があるため、tailwind の base のみ読み込むだけの css を用意して対応する。(rsbuild は @import をデフォでサポートしている)

NG パターン
# これはできない
@tailwind base;
@import 'other.css`;

shadcn-base.css

shadcn の前提となる style を記述

shadcn-base.css
:root {
  --background: 0 0% 100%;
  --foreground: 222.2 47.4% 11.2%;

  --muted: 210 40% 96.1%;
  --muted-foreground: 215.4 16.3% 46.9%;

  --popover: 0 0% 100%;
  --popover-foreground: 222.2 47.4% 11.2%;

  --border: 214.3 31.8% 91.4%;
  --input: 214.3 31.8% 91.4%;

  --card: 0 0% 100%;
  --card-foreground: 222.2 47.4% 11.2%;

  --primary: 220.2 100.4% 20.2%;
  --primary-foreground: 210 40% 98%;

  --secondary: 210 40% 96.1%;
  --secondary-foreground: 222.2 47.4% 11.2%;

  --accent: 210 40% 96.1%;
  --accent-foreground: 222.2 47.4% 11.2%;

  --destructive: 0 100% 50%;
  --destructive-foreground: 210 40% 98%;

  --ring: 215 20.2% 65.1%;

  --radius: 0.5rem;
}

.dark {
  --background: 224 71% 4%;
  --foreground: 213 31% 91%;

  --muted: 223 47% 11%;
  --muted-foreground: 215.4 16.3% 56.9%;

  --accent: 216 34% 17%;
  --accent-foreground: 210 40% 98%;

  --popover: 224 71% 4%;
  --popover-foreground: 215 20.2% 65.1%;

  --border: 216 34% 17%;
  --input: 216 34% 17%;

  --card: 224 71% 4%;
  --card-foreground: 213 31% 91%;

  --primary: 210 40% 98%;
  --primary-foreground: 222.2 47.4% 1.2%;

  --secondary: 222.2 47.4% 11.2%;
  --secondary-foreground: 210 40% 98%;

  --destructive: 0 63% 31%;
  --destructive-foreground: 210 40% 98%;

  --ring: 216 34% 17%;

  --radius: 0.5rem;
}

* {
  @apply border-border;
}
body {
  @apply bg-background text-foreground;
  font-feature-settings: 'rlig' 1, 'calt' 1;
}

globals.css

globals.css
@import 'tailwind-base.css';
@import 'shadcn-base.css';
@tailwind components;
@tailwind utilities;

tailwind base の後に shadcn base を import する。

tailwind-expose.css

tailwind-expose.css
@import 'shadcn-base.css';
@tailwind components;
@tailwind utilities;

tailwind base は必要ないので import しない (app shell 側で存在する前提)

nbstshnbstsh

Custom PostCSS Plugin 更新

方針としては、data selector で module の境界を設ける方針でいくので、 その境界の root となる要素 (= data selector を付与された要素) に対して、shadcn ui が root に規定する css を付与する形でやってみる。

やることとしてはこんな感じ↓

  • :root, body の selector は [data-mf="mf-name"] に変換する
  • 上記以外の selector は [data-mf="mf-name"] で wrap する (e.g. .foo -> [data-mf="mf-name"] .foo)
postcss.config.cjs
const wrapWithDataSelector = (opts = {}) => {
  return {
    postcssPlugin: 'postcss-wrap-with-data-selector',
    Once(root) {
      const mfDataSelector = `[data-mf=${opts.name}]`;
      root.walkRules((rule) => {
        rule.selectors = rule.selectors.map((selector) => {
          // Replace :root and body
          if (selector === ':root' || selector === 'body') {
            return mfDataSelector;
          }
          // Wrap other selectors
          return `${mfDataSelector} ${selector}`;
        });
      });
    },
  };
};

ちゃんと想定通りの css が生成される↓

これで <div data-mf="mf-name"> を境界に shad cn 使えるはず

nbstshnbstsh

tailwind-expose.css のみ css 変換処理実行

local 開発時は data selector による scoping は不要なので、Custom PostCSS Plugin の適用範囲を指定できるようにする。

postcss.config.cjs
const path = require('path');
const wrapWithDataSelector = (opts = {}) => {
  return {
    postcssPlugin: 'postcss-wrap-with-data-selector',
    Once(root, { result }) {
      const targetFiles = opts.files || []; // List of target files
      const filePath = result.opts.from; // Path of the currently processed CSS file
      const fileName = path.basename(filePath);

      // Apply only to specific files
      if (!targetFiles.includes(fileName)) {
        return; // Skip plugin processing for non-target files
      }

      const mfDataSelector = `[data-mf=${opts.name}]`;

      root.walkRules((rule) => {
        rule.selectors = rule.selectors.map((selector) => {
          // Replace :root and body
          if (selector === ':root' || selector === 'body') {
            return mfDataSelector;
          }
          // Wrap other selectors
          return `${mfDataSelector} ${selector}`;
        });
      });
    },
  };
};

module.exports = {
  plugins: [
    require('tailwindcss'),
    wrapWithDataSelector({ name: 'shadcn-mf', files: ['tailwind-expose.css'] }),
  ],
};

これで、"tailwind-expose.css" のみ data selector の付与が行われる。

nbstshnbstsh

remote tailwind 読み込みめんどい問題

remote module の tailwind styles を利用する際に毎回 import しなきゃいけないのは面倒なのでなんとかしたい。それに、利用する側で data selector (data-mf="remote") を指定しなければいけない状態もよろしく無い。内部事情 (data-mf にどんな値を指定すべきか)が外部に漏れ出ちゃってるので remote app 側で完結させるべき。

import React, { lazy, Suspense } from 'react';

const RemoteButton = lazy(() => import('remote/Button'));

const RemoteTailwindWrapper = lazy(() => {
 return import('remote/tailwind-expose.css').then(() => ({
    default: ({ children }: { children: React.ReactNode }) => <div data-mf="remote">{children}</div>
 }));
});

const RemoteTailwindExample: React.FC = () => {
 return (
     <Suspense fallback={<div>Loading button...</div>}>
       <RemoteTailwindWrapper>
         <RemoteButton>I'm a remote button with Tailwind CSS</RemoteButton>
       </RemoteTailwindWrapper>
     </Suspense>
 );
};

export default RemoteTailwindExample;
nbstshnbstsh

remote app 側で tailwind 読み込み用 component 用意

以下を行う component を作成して、Module Federation で expose する。

  • data selector の付与
  • expose 用 tailwind styles (tailwind-expose.css) の読み込み
mf-boundary.tsx
import React from 'react';
import './tailwind-expose.css';

const MfBoundary: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  return <div data-mf='remote'>{children}</div>;
};

export default MfBoundary;
rsbuild.config.ts
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';

export default defineConfig({
  plugins: [pluginReact()],
  server: {
    port: 3001,
  },
  moduleFederation: {
    options: {
      name: 'remote',
      filename: 'remoteEntry.js',
      exposes: {
        './Button': './src/components/Button',
        './tailwind-expose.css': './src/tailwind-expose.css',
+        './MfBoundary': './src/mf-boundary.tsx',
      },
    }
  },
});
nbstshnbstsh

host app 側 MfBoundary component に置き換え

import React, { lazy, Suspense } from 'react';

const MfBoundary = lazy(() => import('remote/MfBoundary'));
const RemoteButton = lazy(() => import('remote/Button'));

- const RemoteTailwindWrapper = lazy(() => {
- return import('remote/tailwind-expose.css').then(() => ({
-    default: ({ children }: { children: React.ReactNode }) => <div data-mf="remote">{children}</div>
- }));
-});

const RemoteTailwindExample: React.FC = () => {
 return (
     <Suspense fallback={<div>Loading button...</div>}>
-     <RemoteTailwindWrapper>
+     <MfBoundary>
         <RemoteButton>I'm a remote button with Tailwind CSS</RemoteButton>
+     </MfBoundary>
-     </RemoteTailwindWrapper>
     </Suspense>
 );
};

export default RemoteTailwindExample;
nbstshnbstsh

shadcn dialog で style 適用されない問題

shadcn ui dialog (= radix ui dialog) は portal を利用しており、html の body 直下に rendering されるため、data selector (data-mf="remote") の範囲外になってしまい style が反映されない。

対応策考える

nbstshnbstsh

shadcn ui dialog 見る

components/ui/dialog.tsx
import * as DialogPrimitive from "@radix-ui/react-dialog"
//...

const DialogPortal = DialogPrimitive.Portal

//...

const DialogContent = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
  <DialogPortal>
    <DialogOverlay />
    <DialogPrimitive.Content
      ref={ref}
      className={cn(
        "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
        className
      )}
      {...props}
    >
      {children}
      <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
        <X className="h-4 w-4" />
        <span className="sr-only">Close</span>
      </DialogPrimitive.Close>
    </DialogPrimitive.Content>
  </DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName

DialogContent の DialogPortal 下が body に rendering され、ここが style が当たらない。

dialog close 時

dialog open 時

body 直下に DialogOverlay と DialogPrimitive.Content が rendering

nbstshnbstsh

対応案1: Portal 下に data-selector 追加

components/ui/dialog.tsx
 export const DialogContent = React.forwardRef<
   React.ElementRef<typeof DialogPrimitive.Content>,
   React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
 >(({ className, children, ...props }, ref) => (
   <DialogPortal>
+    <div data-mf="remote">
       <DialogOverlay />
       <DialogPrimitive.Content
         ref={ref}
         className={cn(
           'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
           className,
         )}
         {...props}
       >
         {children}
         <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
           <X className="h-4 w-4" />
           <span className="sr-only">Close</span>
         </DialogPrimitive.Close>
       </DialogPrimitive.Content>
+    </div>
   </DialogPortal>
 ));

dialog open 時

body 直下に <div data-mf="remote"> が追加され、その下に dialog の portal 内部が rendering

nbstshnbstsh

対応案2: Portal を MfBoundary 内部に固定する

Portal

When used, portals your overlay and content parts into the body.

props

container: Specify a container element to portal the content into. (deafult: document.body)

Radix ui の Portal container prop で portal の描画先を指定できる。デフォで document.body。

https://www.radix-ui.com/primitives/docs/components/dialog#portal

container prop を利用して描画先が必ず data selector (data-mf="remote") の範囲内になるようにする。

MfBoundary component 内に portal の描画先となる div を追加 (わかりやすくするため
id="mf-portal-container" を付与) し、その ref を context で受け渡せるようにする。

components/mf-boundary.tsx
import React, { createContext, useContext, useState } from 'react';

type DialogPortalContext = {
  container?: HTMLDivElement | null;
};

const DialogPortalContext = createContext<DialogPortalContext>({});

const useDialogPortalContext = () => useContext(DialogPortalContext);

export const useMfPortalContainer = () => {
  const { container } = useDialogPortalContext();
  return container;
};

export const MfBoundary: React.FC<{ children: React.ReactNode }> = ({
  children,
}) => {
  const [container, setContainer] = useState<HTMLDivElement | null>(null);

  return (
    <div data-mf="remote">
      <DialogPortalContext.Provider value={{ container }}>
        {children}
      </DialogPortalContext.Provider>
      <div id="mf-portal-container" ref={setContainer} />
    </div>
  );
};

context から ref を受け取り DialogPortal.container prop に指定。

components/ui/dialog.tsx
import { useMfPortalContainer } from '@/components/mf-boundary';

//...

export const DialogContent = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
  <DialogPortal container={useMfPortalContainer()}>
      //...
  </DialogPortal>
));

dialog close 時

dialog open 時

nbstshnbstsh

shadcn ui の animation が消えている問題

dialog の開閉時の animation が亡くなっていることに気付いた。

原因調査していく。

nbstshnbstsh

build された css 見てみる。

@keyframes の block 内に data selector 付与されちゃってるな...

@keyframes enter {

  [data-mf=remote] from {
    opacity: var(--tw-enter-opacity, 1);
    transform: translate3d(var(--tw-enter-translate-x, 0), var(--tw-enter-translate-y, 0), 0) scale3d(var(--tw-enter-scale, 1), var(--tw-enter-scale, 1), var(--tw-enter-scale, 1)) rotate(var(--tw-enter-rotate, 0));
  }
}

@keyframes exit {

  [data-mf=remote] to {
    opacity: var(--tw-exit-opacity, 1);
    transform: translate3d(var(--tw-exit-translate-x, 0), var(--tw-exit-translate-y, 0), 0) scale3d(var(--tw-exit-scale, 1), var(--tw-exit-scale, 1), var(--tw-exit-scale, 1)) rotate(var(--tw-exit-rotate, 0));
  }
}
nbstshnbstsh

custom PostCSS plugin 修正

現状はこちら↓

postcss.config.cjs
const wrapWithDataSelector = (opts = {}) => {
  return {
    postcssPlugin: 'postcss-wrap-with-data-selector',
    Once(root) {
      const mfDataSelector = `[data-mf=${opts.name}]`;
      root.walkRules((rule) => {
        rule.selectors = rule.selectors.map((selector) => {
          // Replace :root and body
          if (selector === ':root' || selector === 'body') {
            return mfDataSelector;
          }
          // Wrap other selectors
          return `${mfDataSelector} ${selector}`;
        });
      });
    },
  };
};

root.walkRules は At-rules(@keyframes, @media とか) 内の css も対象になるので @keyframes 内部の from とか to とかも変更しちゃっているのが原因。

対応として、At-rules 内の rule の場合は scope 対象の At-rule かを判定し、必要ない場合は処理をスキップするよう変更。

以下の At-rules 内は一般的な css が記述されるので scope 対象とする。

  • @media
  • @supports
  • @layer
  • @scope
  • @document
const AT_RULES_WHITE_LIST = ['media', 'supports', 'layer', 'scope', 'document'];

const shouldModifyAtRule = (atRuleName) => {
  return AT_RULES_WHITE_LIST.includes(atRuleName);
};

//...

if (rule.parent && rule.parent.type === 'atrule' && !shouldModifyAtRule(rule.parent.name)) {
  return; // Skip modification for non-whitelisted at-rules
}

修正後

postcss.config.cjs
const AT_RULES_WHITE_LIST = ['media', 'supports', 'layer', 'scope', 'document'];

const shouldModifyAtRule = (atRuleName) => {
  return AT_RULES_WHITE_LIST.includes(atRuleName);
};

const wrapWithDataSelector = (opts = {}) => {
  return {
    postcssPlugin: 'postcss-wrap-with-data-selector',
    Once(root, { result }) {
      const targetFiles = opts.files || []; // List of target files
      const filePath = result.opts.from; // Path of the currently processed CSS file
      const fileName = path.basename(filePath);

      // Apply only to specific files
      if (!targetFiles.includes(fileName)) {
        return; // Skip plugin processing for non-target files
      }

      const mfDataSelector = `[data-mf=${opts.name}]`;

      root.walkRules((rule) => {
        if (
          rule.parent &&
          rule.parent.type === 'atrule' &&
          !shouldModifyAtRule(rule.parent.name)
        ) {
          return; // Skip modification for non-whitelisted at-rules
        }

        rule.selectors = rule.selectors.map((selector) => {
          // Replace :root and body
          if (selector === ':root' || selector === 'body') {
            return mfDataSelector;
          }
          // Wrap other selectors
          return `${mfDataSelector} ${selector}`;
        });
      });
    },
  };
};
nbstshnbstsh

これで @keyframes block 内が変更されることがなくなり問題解決。

ただ、animation name が global になってしまってるのがちょっと気がかり...

nbstshnbstsh

animation-name を scoping

現状の custom PostCSS plugin だと、animation-name はそのままの状態なので他の Module と競合する可能性がある。

丁寧に対応するなら、animation name にも prefix を付与して scoping するのが良さげなので PostCSS plugin 更新していく。

方針としては、

  1. @keyframes の定義をめぐって prefix を付与し、その name を控える。
  2. css declarations (color: red みたいな key value pair) をめぐって、animation-name を prefix が付与されたものに置き換える。
nbstshnbstsh

1. @keyframes の定義をめぐって prefix を付与し、その name を控える

root.walkAtRules で At-rules をめぐれる

const prefix = opts.name ? `${opts.name}-` : ''; // Prefix for scoping
const animationNames = new Map(); // Map to track original and scoped animation names

// Step 1: Scope keyframes names
root.walkAtRules('keyframes', (atRule) => {
  const originalName = atRule.params;
  const scopedName = `${prefix}${originalName}`;
  animationNames.set(originalName, scopedName); // Map original to scoped name

  // Update the keyframes rule with the scoped name
  atRule.params = scopedName;
});
nbstshnbstsh

2. css declarations をめぐって animation-name を prefix が付与されたものに置き換える

root.walkDecls で css declaration をめぐれる

// Step 2: Walk through all CSS rules to update animation usage
root.walkDecls((decl) => {
  if (decl.prop === 'animation' || decl.prop === 'animation-name') {
    // Split animations in case there are multiple animations in a single declaration
    const updatedValue = decl.value
      .split(',')
      .map((animation) => {
        const name = animation.trim().split(' ')[0]; // Get animation name
        return animationNames.has(name)
          ? animation.replace(name, animationNames.get(name)) // Replace with scoped name
          : animation; // Keep original if no match
      })
      .join(', ');

    decl.value = updatedValue; // Update the declaration
  }
});
nbstshnbstsh

関数化する

const scopeAnimationNames = (root, mfName = DEFAULT_MF_NAME) => {
  const prefix = `${mfName}-`; // Prefix for scoping
  const animationNames = new Map(); // Map to track original and scoped animation names

  // Step 1: Scope keyframes names
  root.walkAtRules('keyframes', (atRule) => {
    const originalName = atRule.params;
    const scopedName = `${prefix}${originalName}`;
    animationNames.set(originalName, scopedName); // Map original to scoped name

    // Update the keyframes rule with the scoped name
    atRule.params = scopedName;
  });

  // Step 2: Walk through all CSS rules to update animation usage
  root.walkDecls((decl) => {
    if (decl.prop === 'animation' || decl.prop === 'animation-name') {
      console.log(decl.value);
      // Split animations in case there are multiple animations in a single declaration
      const updatedValue = decl.value
        .split(',')
        .map((animation) => {
          const name = animation.trim().split(' ')[0]; // Get animation name
          return animationNames.has(name)
            ? animation.replace(name, animationNames.get(name)) // Replace with scoped name
            : animation; // Keep original if no match
        })
        .join(', ');

      decl.value = updatedValue; // Update the declaration
    }
  });
};
nbstshnbstsh

tailwind の animate-pulse を利用した例↓

pulse という animation name が shadcn-mf-pulse に変更されている

nbstshnbstsh

Module Federation Scoping PostCSS Plugin

現状の Custom PostCSS Plugin をまとめとく。

もはや data selector で wrap するだけのものではないので名称変更する。Module Federation で expose する style の scoping を行う plugin なので postcss-mf-scoping plugin と命名。

以下の scoping を行う↓

  • data selector による css の scoping
  • prefix による animation name の scoping
postcss.config.cjs
const path = require('path');

const DEFAULT_MF_NAME = 'remote';

const scopeAnimationNames = (root, mfName = DEFAULT_MF_NAME) => {
  const prefix = `${mfName}-`; // Prefix for scoping
  const animationNames = new Map(); // Map to track original and scoped animation names

  // Step 1: Scope keyframes names
  root.walkAtRules('keyframes', (atRule) => {
    const originalName = atRule.params;
    const scopedName = `${prefix}${originalName}`;
    animationNames.set(originalName, scopedName); // Map original to scoped name

    // Update the keyframes rule with the scoped name
    atRule.params = scopedName;
  });

  // Step 2: Walk through all CSS rules to update animation usage
  root.walkDecls((decl) => {
    if (decl.prop === 'animation' || decl.prop === 'animation-name') {
      console.log(decl.value);
      // Split animations in case there are multiple animations in a single declaration
      const updatedValue = decl.value
        .split(',')
        .map((animation) => {
          const name = animation.trim().split(' ')[0]; // Get animation name
          return animationNames.has(name)
            ? animation.replace(name, animationNames.get(name)) // Replace with scoped name
            : animation; // Keep original if no match
        })
        .join(', ');

      decl.value = updatedValue; // Update the declaration
    }
  });
};

const getParentAtRule = (rule) => {
  return rule.parent && rule.parent.type === 'atrule' ? rule.parent.name : null;
};

const AT_RULES_WHITE_LIST = ['media', 'supports', 'layer', 'scope', 'document'];

const shouldModifyAtRule = (atRuleName) => {
  return AT_RULES_WHITE_LIST.includes(atRuleName);
};

const shouldModifyRule = (rule) => {
  const parentAtRule = getParentAtRule(rule);
  if (!parentAtRule) return true;

  return shouldModifyAtRule(parentAtRule.name);
};

const scopeSelectors = (root, mfName = DEFAULT_MF_NAME) => {
  const mfDataSelector = `[data-mf=${mfName}]`;

  root.walkRules((rule) => {
    if (!shouldModifyRule(rule)) return;

    rule.selectors = rule.selectors.map((selector) => {
      // Replace :root and body
      if (selector === ':root' || selector === 'body') {
        return mfDataSelector;
      }
      // Wrap other selectors
      return `${mfDataSelector} ${selector}`;
    });
  });
};

const mfScopingPlugin = (opts = {}) => {
  return {
    postcssPlugin: 'postcss-mf-scoping',
    Once(root, { result }) {
      const targetFiles = opts.files || []; // List of target files
      const filePath = result.opts.from; // Path of the currently processed CSS file
      const fileName = path.basename(filePath);

      // Apply only to specific files
      if (!targetFiles.includes(fileName)) {
        return; // Skip plugin processing for non-target files
      }

      const mfName = opts.name || DEFAULT_MF_NAME;

      scopeAnimationNames(root, mfName);
      scopeSelectors(root, mfName);
    },
  };
};

module.exports = {
  plugins: [
    require('tailwindcss'),
    mfScopingPlugin({
      name: 'shadcn-mf',
      files: ['tailwind-expose.css', 'shadcn-base.css'],
    }),
  ],
};
このスクラップは1ヶ月前にクローズされました