Last year, with the release of React 18, a series of trendy APIs were introduced, providing possibilities for rendering larger-scale web applications. However, these cutting-edge technologies also brought many challenges, particularly in tasks related to server-side rendering. Despite the React 18 Work Group and extensive discussions among developers from various domains during the development process, the ambitious nature of the changes resulted in many ecosystem components still not being adapted even after a year. In contrast, many meta-frameworks quickly caught up, implementing a series of officially recommended best practices. This created a certain division, causing various UI component libraries to have issues rendering correctly on the client-side. In this article, we will take Microsoft's Fluent UI V9 as an example and provide a brief overview of the tasks developers can undertake during this transition period to ensure compatibility between Remix.run and their component libraries.

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.