Module Federation で tailwind の style 共有を考える
tailwind ベースの component を ModuleFederation で共有する際の css の取り扱いについてメモしていく。
rsbuild で host, remote の app を用意し試していく。
そもそも、css file を expose することは可能なのか?
試しに remote app から sample css を expose して host app で組み込んでみる。
expose sample.css
.remote-style {
color: red;
font-size: 20px;
font-weight: bold;
}
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',
},
}
},
});
import sample.css
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 してみる
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 されるっぽい。
ただこれだと、remote から css を取得するまでの間に style が適用されないので、css 取得完了まで fallback view 出したい。
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;
Suspense 使うように refactor
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;
tailwind の css を expoes するにはどうすればいい...?
remote app の src 内の raw css を expose できることはわかったが、tailwind のように PostCSS で生成される CSS はどうすればいい...?
@tailwind directive 使ってる entry css を expose してあげれば bundler が build した成果物が static asset として提供してくれるのか...?
とりあえず index.css を expose してみる。
@tailwind base;
@tailwind components;
@tailwind utilities;
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',
},
}
},
});
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 してあげればいい、ってことになるわな。
@tailwind base
で tailwind の preflight css が生成されるが、こちらは app shell (host-app) 側でセットされるはずなので、remote app から expose する css に含める必要はない。
つまり、以下の css を expose すればOK。
@tailwind components;
@tailwind utilities;
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 で共有できる。
tailwind style conflict
css framework が生成する class が順序に依存していると Micro Frontend の文脈で競合が起きないように注意する必要がある。
tailwind は utility-first な css なので class の順序が変わろうと style の競合は起きないものと考えていたが、例外があった。sm
, md
みたいな responsive utility variants は生成される順序に依存するため競合が起きる。
競合が起きる例
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 する。
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-500
と md:text-blue-500
の順序が入れ替わり、後ろに来た sm:text-red-500
が適用されてしまっている。
こちら参考になりそう
- PostCSS で生成される tailwind class に module ごとの prefix を利用することで競合を回避する。
- className 指定箇所で runtime で prefix を付与する utility を噛ませる
runtime で prefix 付与しなきゃいけないのが微妙...
tailwind 自体にも prefix での対応とそこまで大差ないな...tailwind config の prefix は既存のコード内の class name 全てに prefix を付与する必要がありメンテナンスがしんどいので使いたくはない。
他の方法考える。
prefix つけて、namespace を設けるような方向性は良さげ。
css nesting で namespace 作るってのはどうだ...?
生成された tailwind class を data selector で wrap する方向性で試す。
Custom PostCSS plugin で scoping
remote app で全ての css を [data-mf=app-name]
で wrap する PostCSS plugin を用意する↓
(CSS nesting 使わなくても PostCSS plugin 作れば対応できた)
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 解決↓
shadcn ui を利用した場合の問題点
shadcn ui では、以下の 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
の指定も利用できない。
@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。
: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;
@import
は必ず file の最初に記載する必要があるため、tailwind の base のみ読み込むだけの css を用意して対応する。(rsbuild は @import
をデフォでサポートしている)
NG パターン
# これはできない
@tailwind base;
@import 'other.css`;
shadcn-base.css
shadcn の前提となる style を記述
: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
@import 'tailwind-base.css';
@import 'shadcn-base.css';
@tailwind components;
@tailwind utilities;
tailwind base の後に shadcn base を import する。
tailwind-expose.css
@import 'shadcn-base.css';
@tailwind components;
@tailwind utilities;
tailwind base は必要ないので import しない (app shell 側で存在する前提)
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)
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 使えるはず
tailwind-expose.css のみ css 変換処理実行
local 開発時は data selector による scoping は不要なので、Custom PostCSS Plugin の適用範囲を指定できるようにする。
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 の付与が行われる。
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;
remote app 側で tailwind 読み込み用 component 用意
以下を行う component を作成して、Module Federation で expose する。
- data selector の付与
- expose 用 tailwind styles (tailwind-expose.css) の読み込み
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;
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',
},
}
},
});
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;
これでとりあえず問題解決
shadcn dialog で style 適用されない問題
shadcn ui dialog (= radix ui dialog) は portal を利用しており、html の body 直下に rendering されるため、data selector (data-mf="remote"
) の範囲外になってしまい style が反映されない。
対応策考える
shadcn ui dialog 見る
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
対応案1: Portal 下に data-selector 追加
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
対応案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。
container prop を利用して描画先が必ず data selector (data-mf="remote") の範囲内になるようにする。
MfBoundary component 内に portal の描画先となる div を追加 (わかりやすくするため
id="mf-portal-container"
を付与) し、その ref を context で受け渡せるようにする。
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 に指定。
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 時
shadcn ui の animation が消えている問題
dialog の開閉時の animation が亡くなっていることに気付いた。
原因調査していく。
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));
}
}
custom PostCSS plugin 修正
現状はこちら↓
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
}
修正後
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}`;
});
});
},
};
};
これで @keyframes block 内が変更されることがなくなり問題解決。
ただ、animation name が global になってしまってるのがちょっと気がかり...
animation-name を scoping
現状の custom PostCSS plugin だと、animation-name はそのままの状態なので他の Module と競合する可能性がある。
丁寧に対応するなら、animation name にも prefix を付与して scoping するのが良さげなので PostCSS plugin 更新していく。
方針としては、
- @keyframes の定義をめぐって prefix を付与し、その name を控える。
- css declarations (
color: red
みたいな key value pair) をめぐって、animation-name を prefix が付与されたものに置き換える。
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;
});
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
}
});
関数化する
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
}
});
};
tailwind の animate-pulse
を利用した例↓
pulse
という animation name が shadcn-mf-pulse
に変更されている
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
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'],
}),
],
};