I have been working on an interesting project lately. It is called Remastered. It’s a fullstack web framework based on React, that is heavily influenced by Remix. Like Remix, Remastered offers a fresh take on React, SSR, and application authoring in modern times.
One of the most interesting features Remix provides is error boundaries. Every route can export a component called ErrorBoundary
and whenever an error occurs, the exported component will render. (You can read more about React's error boundaries in the React docs)
This makes perfect sense on the client side. But how does it work on the server? React does not support error boundaries on the server!
Well - I don’t know how Remix does it. But I can tell you how Remastered has solved it.
Warning: this might break in the future or fail to support Suspense in the server.
First, let's split the solution into two parts: the client and the server. The client part is fairly easy, since React already supports error boundaries. The server side needs to have a specific implementation, so we're going to use a custom implementation of error boundaries based on the runtime environment. To do so, let's aim for an error boundary API that would be convenient to use both in the server and in the client:
<ErrorBoundary fallbackComponent={MyErrorBoundaryComponent}>
<MyFragileComponent />
</ErrorBoundary>
If this kinda reminds you Brian Vaughn's react-error-boundary
package, that's good — because this is exactly the implementation of error boundary component I used for the client side code.
Next, we will need to implement the server error boundary. So how's that going to work? The naive implementation, also found in a few other packages in the wild, won't be accurate. Most packages had the following implementation:
// WARNING: this is not the code we're using. It's the naive implementation
// which adds an unnecessary `div` to the tree
import ReactDOM from "react-dom/server";
function MyErrorBoundary(props) {
try {
const html = ReactDOM.renderToStaticMarkup(props.children);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
} catch (e) {
return (
<div>
<props.fallbackComponent error={e} />
</div>
);
}
}
You might have noticed a <div />
. That <div />
is what makes it inaccurate. Avoiding it is kinda hard, but it was a fun experience. The best option would have been to have something like <React.Fragment dangerouslySetInnerHTML={{ __html: html }} />
, but this is not possible at this time of writing, unfortunately.
So what I did (which might be an overkill), was to use an HTML parser and create React Elements on my own:
// This is psuedo code
function htmlToReactElement(html) {
const elements = parseHtml(html);
return elements.map((element) => {
if (element.type === "textNode") {
return element.innerText;
}
const HtmlTag = element.tagName;
const attributes = element.attributes;
const innerHTML = element.innerHTML;
return (
<HtmlTag
{...attributes}
dangerouslySetInnerHTML={{ __html: innerHTML }}
/>
);
});
}
This way, we have not added the root div
. This has quirks though (which I've omitted for clarity): we need to camelCase-ify all the attributes, ignore special attributes, migrate inline style
from being in a text form to an object form, etc.
But it works!
There's an interesting side-effect to using renderToStaticMarkup
. When calling renderToStaticMarkup
with a provided component, we essentially create a new React tree, that is detached from the previous tree. So, if we had contexts (used by utilities like React Router, React Query, SWR, etc), they will all be detached and their values won't be shared between the two trees. This is breaking the abstraction, so we need to fix it, and pass all the values for the contexts manually.
Sadly, this is not something React provides a mechanism for, so we will have to patch the createContext
function for this:
function patch() {
const oldCreateContext = React.createContext;
const contexts = new Set();
// Now we override the `createContext` call, which
// creates React contexts
React.createContext = (...args) => {
// create a context by call the old implementation
const context = oldCreateContext(...args);
// we store all the contexts in a Set
contexts.add(new WeakRef(context));
// and returns the newly created context
return context;
};
return {
getContexts() {
return [...contexts]
.map((c) => {
// Try and get the value out of the WeakRef
return c.deref();
})
.filter((c) => {
// Since we have a WeakRef, the object might have already
// removed from memory. So we need to verify that it exists
return c !== undefined;
});
},
};
}
Note: we're storing a WeakRef because we don't want ownership on the context. We want the garbage collector to ignore the fact we're holding a reference to the context object: having ownership can create memory leaks. Imagine creating contexts on the fly—having a strong reference to them will not allow the GC to remove them from memory, although not being used at all. This is a memory leak!
Now when we want to wrap a component, say - props.children
, we can do that by taking all the contexts from getContexts
and reduce them:
function wrapComponentWithContext(component, contexts) {
return contexts.reduce((wrapped, context) => {
return (
<context.Provider value={React.useContext(context)}>
{wrapped}
</context.Provider>
);
}, component);
}
function MyErrorBoundary(props) {
// --snip--
const subtree = wrapComponentWithContext(props.children);
const html = ReactDOM.renderToStaticMarkup(subtree);
// --snip--
}
So, by rendering a sub-tree of the React component tree using ReactDOM.renderToStaticMarkup
, we are able to catch errors in the backend and optionally render the fallback component.
Now, all we need is to have a module that:
import.meta.SSR
in Vite), exports the SSR boundary shim componentreact-error-boundary
And use this module whenever we are in need of catching errors from React components.
The forthcoming React 18 release will have a streaming API that supports suspense, with the idea that:
<script />
tags will be added (by streaming them) to update the UI stateSince our implementation is using React.renderToStaticMarkup
, we are bypassing the "streaming" part. Not entirely sure how it'll play out, but we'll wait and see!