How to install fluent UI react with remix run? I believe this is important enough to be addressed here, as it is a stumbling block in the adoption of office-UI-fabric-react in the community.
After digging around FluentUI and Remix's documentation and some github issues, this is the approach that worked.
- Edit
root.tsx
. Add{typeof document === "undefined" ? "__STYLES__" : null}
to the head tag. Add{typeof document === "undefined" ? "__SCRIPTS__" : null}
before the closing body tag. - Edit
entry.server.tsx
to include:
import { renderToString } from "react-dom/server"
import { RemixServer } from "remix"
import type { EntryContext } from "remix"
import { resetIds, Stylesheet } from "@fluentui/react"
const sheet = Stylesheet.getInstance()
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
resetIds()
let markup = renderToString(
<RemixServer context={remixContext} url={request.url} />
)
markup = markup.replace(
"__STYLES__",
`<style data-merge-styles="true">${sheet.getRules(true)}</style>`
)
markup = markup.replace(
"__SCRIPTS__",
`<script type="text/javascript">
window.FabricConfig = window.FabricConfig || {};
window.FabricConfig.serializedStylesheet = ${sheet.serialize()};
</script>`
)
responseHeaders.set("Content-Type", "text/html")
return new Response("<!DOCTYPE html>" + markup, {
status: responseStatusCode,
headers: responseHeaders,
})
}
- Restart your remix app
References:
https://remix.run/docs/en/v1/guides/styling#css-in-js-libraries
https://github.com/microsoft/fluentui/wiki/Server-side-rendering-and-browserless-testing#nextjs-setup
https://github.com/microsoft/fluentui/issues/11411
If you would like to load fluentui icons as well, import initializeIcons
into your pages and call it outside any function
entry.server.tsx
? –
Middlesworth I wrote one answer several months ago on GitHub, here's the original article:
The Renderer
One significant characteristic of Remix is its tightly encapsulated component structure, which prevents developers from directly modifying the behavior of the bundler or certain components. For example, many component libraries require a renderer to collect all CSS rules generated during the rendering process. Developers need to wrap the renderer's corresponding context around the component to ensure it can gather all rendering context information. Since this component is specific to the server-side, this work needs to be done in the Node Server script.
However, if you have used Remix, you may find this challenging because the server-side rendering logic is encapsulated within the <RemixServer />
component. It contains special logic related to server-side rendering and exception handling. Inside this component, there is only the client-side root component.
Of course, we can attempt to wrap the SSR-related context components outside the RemixServer
, but this is not always effective. For example, in Fluent UI V9, there are two providers: RendererProvider
and SSRProvider
. If developers try to wrap them outside the RemixServer
, it will result in errors during server-side rendering.
In such cases, we need to create an empty React Context and pass this renderer component to the client-side script. For example:
import * as React from "react";
import {
renderToStyleElements,
type GriffelRenderer,
} from "@fluentui/react-components";
export const FluentStyleContext = React.createContext<GriffelRenderer | null>(
null
);
Next, we need to modify the entry.server.tsx
file to ensure that your Context wraps around the RemixServer component. Since our Context doesn't have any side effects, it is safe to wrap it here.
<FluentStyleContext.Provider value={renderer}>
<RemixServer context={remixContext} url={request.url} />
</FluentStyleContext.Provider>
Then, inside the client-side script, we can wrap the necessary components. However, please note that we don't want these two components to appear in the client-side script. So, we need to find a clever workaround to isolate this part of the work. Remix provides a feature for this purpose: if a JavaScript file's name includes .server.tsx
, it won't be included in the client-side script bundle. Therefore, leveraging this feature, we can create a new file called fluent.server.tsx
and write the following component:
import * as React from "react";
import {
SSRProvider,
RendererProvider,
} from "@fluentui/react-components";
import type { GriffelRenderer } from "@fluentui/react-components";
import { FluentStyleContext } from "~/context/fluentStyleContext";
export const FluentServerWrapper: React.FC<React.PropsWithChildren> = ({
children,
}) => {
const renderer = React.useContext(FluentStyleContext)!;
return (
<RendererProvider renderer={renderer}>
<SSRProvider>{children}</SSRProvider>
</RendererProvider>
);
};
Next, we create a corresponding client-side component in root.tsx
:
import { FluentServerWrapper } from "./utils/fluent.server";
const FluentClientWrapper: React.FC<React.PropsWithChildren> = ({
children,
}) => {
return <>{children}</>;
};
const FluentWrapper = FluentServerWrapper ?? FluentClientWrapper;
Finally, wrap all the content inside the <body>
tag with the FluentServerWrapper
. With this, the injection of the Context is completed.
Downgrade the streaming rendering process
React 18 introduces a new method for streaming rendering of the virtual DOM called renderToReadableStream
. However, many CSS-in-JS solutions do not support this approach because they assume that the virtual DOM must be fully rendered before the final CSS rendering can take place. If we use the streaming generation method to output HTML, we will find that the generated stylesheet contains no information. This is because stylesheets typically appear in the <head>
tag, and at the point where the virtual DOM is generated, no components have been rendered yet, so no content is available.
To address this issue, we need to downgrade the server-side rendering method until our frontend framework supports the corresponding functionality. Here, we open the entry.server.tsx
file and make the following changes:
import { RemixServer } from "@remix-run/react";
import {
createDOMRenderer,
renderToStyleElements,
} from "@fluentui/react-components";
import { renderToStaticMarkup } from "react-dom/server";
// ...
export default async function handleRequest(
// ...
) {
const renderer = createDOMRenderer();
let body = renderToStaticMarkup(
<FluentStyleContext.Provider value={renderer}>
<RemixServer context={remixContext} url={request.url} />
</FluentStyleContext.Provider>
);
const $style = renderToStaticMarkup(<>{renderToStyleElements(renderer)}</>);
//...
}
Style sheet injection
Both Remix.run and Next.js take control of the complete DOM tree generation at the React level. However, Remix.run does not provide an API for injecting style sheets, so we need to manually manipulate the HTML.
For the server-side, we need to prepare a marker to search for and replace the generated style sheet tag. Here's how it can be done:
Add a new component in fluent.server.tsx:
export const FluentServerStyle = () => {
return <style id="fui-hydration-marker" />;
};
In the entry.server.tsx file, we will search for this marker and replace it with the generated style sheet:
body = body.replace(`<style id="fui-hydration-marker"></style>`, $style);
responseHeaders.set("Content-Type", "text/html");
return new Response(body, {
headers: responseHeaders,
status: responseStatusCode,
});
However, simply doing that is not enough because we may encounter inconsistencies between client-side and server-side rendering. Unlike the old version of the server-side rendering API, for the hydrateRoot
function, if React detects a mismatch between the client's virtual DOM and the HTML returned by the server, it will throw an error and refuse to proceed with rendering. Therefore, on the client-side, we need to create a component to "reconcile" the server-side rendering result. The specific component should look like this:
import useConstant from "use-constant";
const FluentClientWrapper: React.FC<React.PropsWithChildren> = ({
children,
}) => {
return <>{children}</>;
};
const FluentClientStyle = () => {
const styles = useConstant(() => {
const $styles = [
...document.head.querySelectorAll("style[data-make-styles-bucket]"),
] as HTMLStyleElement[];
const configs = $styles.map((x) => ({
props: x.getAttributeNames().reduce((acc, name) => {
return { ...acc, [name]: x.getAttribute(name) };
}, {}),
children: x.innerHTML,
}));
return configs;
});
const vDom = (
<>
{styles.map(({ props, children }, i) => (
<style key={i} {...props}>{`${children}`}</style>
))}
</>
);
return vDom;
};
Please note that useConstant
is not a built-in React component; you need to install it from NPM. This component performs a simple task: scanning the <head>
section of the HTML document, finding <style>
tags that meet certain conditions, and generating the corresponding virtual DOM elements. For Fluent UI, the filtering criterion is that the component has the data-make-styles-bucket attribute
. However, style sheet tags in other frameworks may have different characteristics, so developers need to design different filtering criteria based on their own situation.
Next, let's build a component that works differently on the client-side and server-side using the same technique:
const FluentStyle = FluentServerStyle ?? FluentClientStyle;
Finally, we just need to place this component in any location within the <head />
tag to ensure proper hydration execution.
Downgrading the Transition Hydration Process
Another new feature provided by React 18 is Transition, which allows breaking down large tasks into smaller microtasks and arranging them in a priority queue. This mechanism helps the client-side respond more quickly to user input: when a user action occurs, the corresponding asynchronous task is immediately prioritized in the async queue to ensure an immediate response.
The hydration process also adapts to this mechanism. The traditional hydration process is blocking, which means that until hydration is complete, users cannot perform any actions, and the interface appears to be stuck. However, the new version of React improves this process by transforming hydration into a streaming process. React completes HTML parsing and event binding while waiting for user input. If a user triggers an event, the hydration process is immediately suspended until the task is completed before continuing with hydration.
This brings significant performance advantages but also introduces potential issues. For example, Fluent UI uses Tabster
to handle accessibility tasks such as focus management and keyboard navigation, but this library itself modifies the DOM structure.
In the traditional hydration flow, React must complete hydration before Tabster
gets involved in its processing, including modifying the DOM. However, the new hydration mechanism disrupts this assumption. Tabster
is invoked during component hydration. Once it modifies the DOM structure, subsequent hydration work will encounter errors due to DOM inconsistencies, leading to crashes in the client-side application.
To address this issue, we need to downgrade the hydration method on the client-side. The approach is straightforward. Open entry.client.tsx
and remove the startTransition
call. The entire file will look like this:
import { StrictMode } from "react";
import { RemixBrowser } from "@remix-run/react";
import { hydrateRoot } from "react-dom/client";
window.setTimeout(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser />
</StrictMode>
);
}, 0);
With this, the hydration process should no longer encounter issues.
Conclusion
For the foreseeable future, it may be challenging for major component libraries to adapt to the extensive architectural changes introduced by React 18. Some CSS-in-JS solutions may even terminate their development efforts due to being too "dynamic" to be "statically analyzed." This has a significant impact on downstream developers as well. The hope is that these simple experiences can help developers smoothly navigate through this turbulent transition period. Additionally, I wish for the entire React ecosystem to quickly adapt to this "architecture earthquake" and once again provide developers with a smooth development experience.
This is the way I have added and it works:
import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
import { getUser } from "~/session.server";
import {
FluentProvider,
webLightTheme,
} from "@fluentui/react-components";
export const loader = async ({ request }: LoaderArgs) => {
return json({ user: await getUser(request) });
};
export default function App() {
return (
<html lang="en" className="h-full">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<Meta />
<Links />
</head>
<body className="h-full">
<FluentTheme>
<Outlet />
</FluentTheme>
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
function FluentTheme({ children }: { children: React.ReactNode }) {
return (
<FluentProvider theme={webLightTheme}>
{children}
</FluentProvider>
);
}
© 2022 - 2024 — McMap. All rights reserved.