Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Next.js SSR Hydration with Children #447

Merged
merged 3 commits into from
Aug 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/example-project/next-app/src/app/Input/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ function Input() {
<MyInput
onMyInput={(ev) => setInputEvent(`${ev.target.value}`)}
onMyChange={(ev) => setChangeEvent(`${ev.detail.value}`)}
/>
>
{/* the following space makes the hydration error go away */}
{' '}
Comment on lines +16 to +17
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This a limitation we need to call out anywhere?

</MyInput>
<div className="inputResult">
<p>Input Event: {inputEvent}</p>
<p>Change Event: {changeEvent}</p>
Expand Down
3 changes: 3 additions & 0 deletions packages/react-output-target/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"devDependencies": {
"@types/node": "^20.14.12",
"@types/react": "^18.2.74",
"@types/react-dom": "^18.3.0",
"react": "^18.2.0",
"ts-dedent": "^2.2.0",
"typescript": "^5.4.4",
Expand All @@ -50,7 +51,9 @@
"gitHead": "a3588e905186a0e86e7f88418fd5b2f9531b55e0",
"dependencies": {
"@lit/react": "^1.0.4",
"decamelize": "^6.0.0",
"html-react-parser": "^5.1.10",
"react-dom": "^18.3.1",
"ts-morph": "^22.0.0"
},
"peerDependenciesMeta": {
Expand Down
184 changes: 141 additions & 43 deletions packages/react-output-target/src/react/ssr.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import decamelize from 'decamelize';
import type { EventName, ReactWebComponent, WebComponentProps } from '@lit/react';

// A key value map matching React prop names to event names.
Expand Down Expand Up @@ -32,75 +34,83 @@ export const createComponentForServerSideRendering = <I extends HTMLElement, E e
options: CreateComponentForServerSideRenderingOptions
) => {
return (async ({ children, ...props }: StencilProps<I> = {}) => {
/**
* if `__resolveTagName` is set we should return the tag name as we are shallow parsing the light dom
* of a Stencil component via `ReactDOMServer.renderToString`
*/
if (props.__resolveTagName) {
return options.tagName;
}

/**
* ensure we only run on server
*/
if (!('process' in globalThis) || typeof window !== 'undefined') {
throw new Error('`createComponentForServerSideRendering` can only be run on the server');
}

/**
* Serialize the props into a string. We only want to serialize string, number and boolean values
* as other values can't be represented within a string.
*/
let stringProps = '';
for (const [key, value] of Object.entries(props)) {
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
continue;
}
stringProps += ` ${key}="${value}"`;
stringProps += ` ${decamelize(key, {separator: '-'})}="${value}"`;
}

let serializedChildren = '';
const toSerialize = `<${options.tagName}${stringProps}>`;
try {
/**
* Attempt to serialize the children of the component into a string to allow the Stencil component
* make judgments about the Light DOM to render itself in specific ways. For example, a component
* may render additional classes or attributes to the host element based on the e.g. a certain slot
* element was passed in or not.
*/
const awaitedChildren = await resolveComponentTypes(children);
serializedChildren = ReactDOMServer.renderToString(awaitedChildren);
} catch (err: unknown) {
/**
* We've experienced that ReactDOMServer would throw `undefined` errors when trying to serialize
* certain React components. This is a best effort to catch the error and log it to the console.
*/
const error = err instanceof Error ? err : new Error('Unknown error');
console.log(
`Failed to serialize light DOM for ${toSerialize.slice(0, -1)} />: ${
error.message
} - this may impact the hydration of the component`
);
}

const toSerialize = `<${options.tagName}${stringProps}></${options.tagName}>`;
const toSerializeWithChildren = `${toSerialize}${serializedChildren}</${options.tagName}>`;

/**
* first render the component with pretty HTML so it makes it easier to
* ....
*/
const { html: captureTags } = await options.renderToString(toSerialize, {
const { html } = await options.renderToString(toSerializeWithChildren, {
fullDocument: false,
serializeShadowRoot: true,
prettyHtml: true,
});

/**
* then, cut out the outer html tag, which will always be the first and last line of the `captureTags` string, e.g.
* ```html
* <my-component ...props>
* ...
* </my-component>
* ```
*/
const startTag = captureTags?.split('\n')[0] || '';
const endTag = captureTags?.split('\n').slice(-1)[0] || '';

/**
* re-render the component without formatting, as it causes hydration issues - we can
* now use `startTag` and `endTag` to cut out the inner content of the component
*/
const { html } = await options.renderToString(toSerialize, {
fullDocument: false,
serializeShadowRoot: true,
});
if (!html) {
throw new Error('No HTML returned from renderToString');
}

const serializedComponentByLine = html.split('\n');
const hydrationComment = '<!--r.1-->';
const isShadowComponent = serializedComponentByLine[1].includes('shadowrootmode="open"');
let templateContent: undefined | string = undefined;

/**
* cut out the inner content of the component
* If the component is a shadow component we need to extract the template content out of the
* shadow root so that we can later compose it back into a React component that gets this
* content applied via "dangerouslySetInnerHTML" and allows us to pass through the children
* as original React components rather than using our own serialization which may not work.
*/
const templateStartTag = '<template shadowrootmode="open">';
const templateEndTag = '</template>';
const hydrationComment = '<!--r.1-->';
const isShadowComponent = html.slice(startTag.length, -endTag.length).startsWith(templateStartTag);
const __html = isShadowComponent
? html
.slice(startTag.length, -endTag.length)
.slice(templateStartTag.length, -(templateEndTag + hydrationComment).length)
: html.slice(startTag.length, -endTag.length);
if (isShadowComponent) {
const templateEndTag = ' </template>';
templateContent = serializedComponentByLine
.slice(2, serializedComponentByLine.indexOf(templateEndTag))
.join('\n')
.trim();
}

/**
* `html-react-parser` is a Node.js dependency so we should make sure to only import it when run on the server
Expand All @@ -115,6 +125,7 @@ export const createComponentForServerSideRendering = <I extends HTMLElement, E e
transform(reactNode, domNode) {
if ('name' in domNode && domNode.name === options.tagName) {
const props = (reactNode as any).props;

/**
* remove the outer tag (e.g. `options.tagName`) so we only have the inner content
*/
Expand All @@ -124,15 +135,28 @@ export const createComponentForServerSideRendering = <I extends HTMLElement, E e
* if the component is not a shadow component we can render it with the light DOM only
*/
if (!isShadowComponent) {
return <CustomTag {...props}>{children}</CustomTag>;
const { children, ...customProps } = props || {};
const __html = serializedComponentByLine
// remove the components outer tags as we want to set the inner content only
.slice(1, -1)
// bring the array back to a string
.join('\n')
.trim()
// remove any whitespace between tags that may cause hydration errors
.replace(/(?<=>)\s+(?=<)/g, '');

return (
<CustomTag {...customProps} suppressHydrationWarning={true} dangerouslySetInnerHTML={{ __html }} />
);
}

return (
<CustomTag {...props}>
<CustomTag {...props} suppressHydrationWarning>
<template
// @ts-expect-error
shadowrootmode="open"
dangerouslySetInnerHTML={{ __html: hydrationComment + __html }}
suppressHydrationWarning={true}
dangerouslySetInnerHTML={{ __html: hydrationComment + templateContent }}
></template>
{children}
</CustomTag>
Expand All @@ -146,3 +170,77 @@ export const createComponentForServerSideRendering = <I extends HTMLElement, E e
return <StencilElement />;
}) as unknown as ReactWebComponent<I, E>;
};

/**
* This function is an attempt to resolve the component types and their children when components are lazy loaded.
* This is a best effort and may not work in all cases. It is recommended to use this function as a starting point
* and replace it with a more robust solution if needed.
*
* @param children {React.ReactNode} - the children of a component
* @returns {Promise<React.ReactNode>} - the resolved children
*/
async function resolveComponentTypes<I extends HTMLElement>(children: React.ReactNode): Promise<React.ReactNode> {
if (typeof children === 'undefined') {
return;
}

/**
* if the children are a string we can return them as is, e.g.
* `<div>Hello World</div>`
*/
if (typeof children === 'string') {
return children;
}

if (!children || !Array.isArray(children)) {
return [];
}

return Promise.all(
children.map(async (child): Promise<undefined | string | StencilProps<I>> => {
if (typeof child === 'string') {
return child;
}

const newProps = {
...child.props,
children:
typeof child.props.children === 'string'
? child.props.children
: await resolveComponentTypes((child.props || {}).children),
};

let type = typeof child.type === 'function' ? await child.type({ __resolveTagName: true }) : child.type;
if (type._payload && 'then' in type._payload) {
type = {
...type,
_payload: await type._payload,
};
}

if (typeof type?._payload === 'function') {
type = {
...type,
$$typeof: Symbol('react.element'),
_payload: await type._payload({ __resolveTagName: true }),
};
}

if (typeof type?.type === 'function') {
return type.type(newProps);
}

if (typeof type?._init === 'function') {
return undefined
}

const newChild = {
...child,
type,
props: newProps,
};

return newChild;
})
) as Promise<React.ReactNode>;
}
29 changes: 21 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading