Skip to content

Commit

Permalink
RFC: introduce SSRProvider to handle id generation & Portals rendering (
Browse files Browse the repository at this point in the history
#17938)

* Create ssr-context.md

* Update ssr-context.md

* Update ssr-context.md

* Update ssr-context.md

* Update ssr-context.md

* Update ssr-context.md

* Update rfcs/convergence/ssr-context.md

Co-authored-by: Oleksandr Fediashov <[email protected]>

* prettier

Co-authored-by: Oleksandr Fediashov <[email protected]>
  • Loading branch information
ling1726 and layershifter authored May 19, 2021
1 parent 4ea94c9 commit a9eda24
Showing 1 changed file with 219 additions and 0 deletions.
219 changes: 219 additions & 0 deletions rfcs/convergence/ssr-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
# 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](https://reactjs.org/docs/react-dom.html#hydrate) 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:

- [facebook/react#5867](https://github.com/facebook/react/issues/5867)
- [facebook/react#4000](https://github.com/facebook/react/issues/4000)

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](https://reactjs.org/docs/context.html#reactcreatecontext) 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. -->

0 comments on commit a9eda24

Please sign in to comment.