Back to Fluentui

RFC: SSR Context

docs/react-v9/contributing/rfcs/react-components/convergence/ssr-context.md

4.40.2-hotfix26.7 KB
Original Source

RFC: SSR Context

@ling1726 @layershifter

Summary

This RFC proposes a specific SSR context will become a requirement for consumers that want to render SSR apps with Fluent.

Background

SSR or Isomorphic apps are first rendered in the server before being delivered to the client. This is generally with code that renders the React app on the server and an additional hydrate step on the client where React will attempt to attach event listeners to the existing markup.

tsx
// render app to static HTML on the server
ReactDOMServer.renderToString(<App />);
res.writeHead(200, { 'Content-Type': 'text/html' });

// On the client
const root = document.getElementById('root');
ReactDOM.hydrate(<App />, root);

The React documentation explicitly mentions that server content and the client's first render to be identical.

React expects that the rendered content is identical between the server and the client. It can patch up differences in text content, but you should treat mismatches as bugs and fix them

Problem statement

The proposal is intended to solve two specific problems that we currently have, and possibly more problems regarding SSR in the future.

Autogenerated Ids

Fluent UI wants autogenerated Ids, because they allow us to make accessibility requirements easier for our consumers by autogenerationg Aria relationshipts (e.g. aria-describedby).

This is not a unique problem, here are issues even in React:

The current useId prop is not SSR safe because it will increment a constant number as an Id

tsx
// Same code run on server and client
let id = 0;
export function getId() {
  return id++;
}

const useId = () => {
  const ref = React.useRef();
  if (ref.current) {
    ref.current = getId();
  }
};

This might work fine intially (probably not though), but the server will keep that global id value growing while on the client it will reset to 0 on every page refresh.

React will warn since the server output do not match the client render during hydration

tsx
// server output
<div id=1 />

//client output
<div id=0 />

Portals that are always rendered (Tooltip)

Tooltips generally need to be always rendered on the page since they use aria-describedby or aria-labelledby relationships. These relatioships need to refer to actual DOM elements for screen readers.

Tooltips should also be rendered out of order of the DOM to avoid unnecessary overflow and render above page content.

⚠⚠⚠ document does not exist on the server

A naive example that will break during server render

tsx
// Naive client implementation -> throws
const tooltipEl = document.createElement('div');
document.appendChild(tooltipEl);
React.createPortal(tooltip, tooltipEl);

Let's try to avoid throwing

tsx
// Avoid throwing
let toolTipEl;

if (typeof document === 'object') {
  const tooltipEl = document.createElement('div');
  document.appendChild(tooltipEl);
}

if (!tooltipEl) {
  React.createPortal(tooltip, tooltipEl)
}

return null;

// Server render
<!--Nothing-->

// Client render
<div>tooltip</div>

Now the app will successfully render, but React hydration will throw a warning because the server render and client render do not match. One proposal might be to inline render the tooltip in SSR, but the problem remains the same. Here is the ideal render flow:

tsx
/** Null render solution */
// Server render
<!--Nothing-->

// First client render
<!--Nothing-->

// Second client render (after hydration)
<div>tooltip</div>

/** Render tooltip inline */
// Server render
<button>Tooltip target</button>
<div>tooltip</div>

// First client render
<button>Tooltip target</button>
<div>tooltip</div>

// Second client render (after hydration)
<button>Tooltip target</button>
...
// somewhere on document.body
<div>tooltip</div>

Detailed Design or Proposal

This RFC proposes a new SSRContext and SSRProvider that needs to wrap around a user SSR app. This will be a necessary contract for SSR apps using Fluent.

tsx
<FluentProvider>
  <SSRProvider>
    <App />
  </SSRProvider>
</FluentProvider

SSRProvider will make all React children aware that they are being used in an SSR context.

Autogenerated Ids

SSRProvider will keep the count of ids that is currently being doing with a global value in the the useId module as described in examples above.

By leveraging React context default value we can also ensure that the same mechanism still works without the SSRProvider for client only apps.

tsx
// behaves just like a global `let id = 0`
const defaultSSRContext = { current: 0 };
SSRContext = React.createContext(defaultSSRContext);

const useId = () => {
  const context = React.useContext(SSRContext);
  return React.useMemo(() => ++context.current, [context]);
};

Nested SSRProviders can just inherit the value for the previous context for consistency in the tree

Sibling SSRProviders are a problem. The solution is to seed all SSRProvider ids with sufficiently random value.

Portal rendering

The Portal component can be aware of SSR state by consuming context and forcing a rerender after first server render.

tsx
import { defaultContext, useSSRContext } from 'context';

// if the ssrContext is the default value -> we are not in SSR
// no probem with first render
const [shouldRender, setShouldRender] =
  React.useState(ssrContextValue === defaultSSRContextValue );

// This if statement technically breaks the rules of hooks, but is safe because the condition never changes after
    // mounting.
if (!isSSR()) {
    // Force second render after app is hydrated
    React.useLayoutEffect(() => {
      if (!shouldRender) {
        setShouldRender(true);
      }
    }, [])

Pros and Cons

Pros:

  • Autogenerated Ids are safe to use in SSR
  • Portal will be SSR safe
  • Same mechanism for other Fluent components to be SSR safe
  • Consumers can use our SSR context as a utility but are not bound to it

Cons:

  • Extra requirement for consumers that use SSR
  • More bundlesize for consumers that use SSR
<!-- Enumerate the pros and cons of the proposal. Make sure to think about and be clear on the cons or drawbacks of this propsoal. If there are multiple proposals include this for each. -->

Discarded Solutions

<!-- As you enumerate possible solutions, try to keep track of the discarded ones. This should include why we discarded the solution. -->

Open Issues

<!-- Optional section, but useful for first drafts. Use this section to track open issues on unanswered questions regarding the design or proposal. -->