Skip to content

Instantly share code, notes, and snippets.

@digitalsadhu
Last active December 13, 2023 23:59
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save digitalsadhu/c012204cc06838902491880ed1f8651e to your computer and use it in GitHub Desktop.
Save digitalsadhu/c012204cc06838902491880ed1f8651e to your computer and use it in GitHub Desktop.
SSR Custom Elements in a React SSR application

This is a very challenging space with multiple moving parts. React is not super friendly to DSD when hydrating. The Lit team has created packages to try to make things "play nice" but as we shall see, this is not smooth sailing.

A basic solution

Heres a simple Lit element component that we register as my-web-component.

import { LitElement, html, css } from "lit";

export class MyWebComponent extends LitElement {
  static styles = css`
    .heading {
      color: #15156d;
      font-family: Arial, Helvetica, sans-serif;
    }
  `;

  render() {
    return html`<h1 class="heading">My server rendered web component</h1>`;
  }
}

customElements.define("my-web-component", MyWebComponent);

We then wrap it using @lit/react

import React from 'react';
import { createComponent } from '@lit/react';
import { MyWebComponent } from './my-web-component.js';

export const MyWrappedWebComponent = createComponent({
  tagName: 'my-web-component',
  elementClass: MyWebComponent,
  react: React
});

and finally we use it in our React app. We import @lit-labs/ssr-react/enable-lit-ssr.js to get things to work properly in the Node.js context.

import React from "react";
import '@lit-labs/ssr-react/enable-lit-ssr.js';
import { MyWrappedWebComponent } from './my-wrapped-component.js';

function App() {
  return (
    <div className="App">
      This is a test
      <MyWrappedWebComponent />
    </div>
  );
}

export default App;

Now, when we SSR our React app, everything works nicely!

The trouble with Next

Unfortunately, this solution won't work in Next v13 or above and theres currently no solution from the Lit team though they plan to look into it.

See

import React from 'react';
import { AssetJs, AssetCss } from '@podium/utils';
type PodletProps = {
className?: string;
content: string;
css?: AssetCss[];
js?: AssetJs[];
};
export function Podlet(props: PodletProps) {
const { className, content, css = [], js = [] } = props;
const containerRef = React.useRef<HTMLDivElement>(null);
React.useEffect(
function reloadPodletOnClientSideRender() {
const containerEle = containerRef.current;
if (containerEle) {
attachTemplatesAsShadowDom(containerEle);
js.forEach(reloadPodletScript);
}
},
[containerRef, css, js],
);
return (
<div
className={className}
dangerouslySetInnerHTML={{ __html: content }}
ref={containerRef}
// This is a hack to suppress the React hydration warning that is
// caused by the <template shadowrootmode="open"> element that is
// used by the podlet. The issues are related to the fact that
// <template shadowrootmode="open"> gets removed by supported
// browsers when the HTML is parsed, causing React to complain
// about mismatching DOM structures between the server and the
// client. Works only in development mode.
// See
// https://github.com/mfreed7/declarative-shadow-dom#-behavior
suppressHydrationWarning
></div>
);
}
function attachTemplatesAsShadowDom(root: Element | ShadowRoot) {
const templates = root.querySelectorAll('template[shadowrootmode]');
templates.forEach((template) => {
const delegatesFocus = template.hasAttribute(
'shadowrootdelegatesfocus',
);
const mode = template.getAttribute('shadowrootmode');
const templateParent = template.parentNode;
if (mode && templateParent) {
const shadowRoot = (templateParent as Element).attachShadow({
delegatesFocus,
mode: mode as ShadowRootMode,
});
shadowRoot.appendChild((template as HTMLTemplateElement).content);
template.remove();
attachTemplatesAsShadowDom(shadowRoot);
}
});
}
function reloadPodletScript(script: AssetJs) {
const scriptEle = document.createElement('script');
scriptEle.crossOrigin = script.crossOrigin;
scriptEle.defer = script.defer;
scriptEle.type = script.type;
document.body.appendChild(scriptEle);
const reloadScriptUrl = script.value ? `${script.value}#rerun` : '';
scriptEle.src = reloadScriptUrl;
}
import React, { useEffect, useRef } from 'react';
import type { PodletContentOrNothing } from '../interfaces';
type PodletProps = {
content: PodletContentOrNothing;
defer?: boolean;
noCss?: boolean;
};
function attachShadow(template: Element): void {
const mode = template.getAttribute('shadowrootmode');
const parentNode = template.parentNode;
if (mode && parentNode) {
try {
const shadowRoot = (parentNode as Element).attachShadow({
mode: mode as ShadowRootMode,
});
shadowRoot.appendChild((template as HTMLTemplateElement).content);
template.remove();
// Attach any nested shadow roots
const templates = shadowRoot.querySelectorAll('template[shadowrootmode]');
for (const t of templates) {
attachShadow(t);
}
} catch (e) {
console.warn('Podlet has already reattached itself. Coordinate responsibilities with the podlet authors.', e, parentNode);
}
}
}
export const Podlet = ({ content }: PodletProps): JSX.Element | null => {
const containerRef = useRef<HTMLDivElement>(null);
const scriptElements = useRef<HTMLScriptElement[]>([]);
const linkElements = useRef<HTMLLinkElement[]>([]);
useEffect(
function attachShadowDomOnClientSideRender() {
const container = containerRef.current;
if (!container) {
return;
}
const templates = container.querySelectorAll('template[shadowrootmode]');
for (const template of templates) {
attachShadow(template);
}
try {
for (const existingLink of linkElements.current) {
document.head.removeChild(existingLink);
}
for (const existingScript of scriptElements.current) {
document.body.removeChild(existingScript);
}
} catch {
// React may have already cleaned up the element, in which case removeChild will throw
}
for (const script of content?.js || []) {
const scriptEle = document.createElement('script');
scriptEle.crossOrigin = 'anonymous';
scriptEle.defer = true;
scriptEle.type = 'module';
document.body.appendChild(scriptEle);
scriptElements.current.push(scriptEle);
scriptEle.src = `${script.value}#rerun` ?? '';
}
for (const link of content?.css || []) {
const linkEle = document.createElement('link');
linkEle.rel = 'stylesheet';
linkEle.href = link.value;
document.head.appendChild(linkEle);
linkElements.current.push(linkEle);
}
},
[containerRef, content?.css, content?.js],
);
if (!content) {
return null;
}
return (
<div
dangerouslySetInnerHTML={{
__html: content.content,
}}
ref={containerRef}
/>
);
};
export const PodletRenderer: (podletContent: PodletContentOrNothing, defer?: boolean, noCss?: boolean) => JSX.Element = (
podletContent: PodletContentOrNothing,
defer = true,
noCss = false,
) => {
return <Podlet content={podletContent} defer={defer} noCss={noCss} />;
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment