Skip to content

Commit

Permalink
fix(react): ssr hydration with children (#464)
Browse files Browse the repository at this point in the history
  • Loading branch information
christian-bromann authored Aug 28, 2024
1 parent 75c6cce commit 48da093
Show file tree
Hide file tree
Showing 7 changed files with 1,168 additions and 213 deletions.
17 changes: 9 additions & 8 deletions packages/example-project/next-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"start": "next start",
"lint": "next lint",
"prettier": "prettier --write --print-width 80 \"*.mjs\" \"*.json\" \"src/**/*.tsx\" \"*.ts\"",
"wdio": "wdio run ./wdio.conf.mts"
"test": "wdio run ./wdio.conf.mts"
},
"dependencies": {
"html-react-parser": "^5.1.10",
Expand All @@ -21,21 +21,22 @@
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@wdio/cli": "^8.39.1",
"@wdio/globals": "^8.36.0",
"@wdio/local-runner": "^8.39.1",
"@wdio/mocha-framework": "^8.39.0",
"@wdio/spec-reporter": "^8.39.0",
"@wdio/types": "^8.39.0",
"@wdio/cli": "^9.0.7",
"@wdio/globals": "^9.0.7",
"@wdio/local-runner": "^9.0.7",
"@wdio/mocha-framework": "^9.0.6",
"@wdio/spec-reporter": "^9.0.7",
"@wdio/types": "^9.0.4",
"autoprefixer": "^10.0.1",
"component-library": "workspace:*",
"component-library-react": "workspace:*",
"eslint": "^8",
"eslint-config-next": "14.1.4",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"ts-node": "^10.9.2",
"tsx": "^4.19.0",
"typescript": "^5",
"webdriverio": "^9.0.7",
"wdio-next-service": "^0.1.0"
},
"volta": {
Expand Down
4 changes: 3 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,9 @@ function Input() {
<MyInput
onMyInput={(ev) => setInputEvent(`${ev.target.value}`)}
onMyChange={(ev) => setChangeEvent(`${ev.detail.value}`)}
/>
>
{' '}
</MyInput>
<div className="inputResult">
<p>Input Event: {inputEvent}</p>
<p>Change Event: {changeEvent}</p>
Expand Down
14 changes: 11 additions & 3 deletions packages/example-project/next-app/test/specs/test.e2e.mts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
/// <reference types="@wdio/mocha-framework" />
/// <reference types="webdriverio" />
import { expect, $, browser } from '@wdio/globals';

describe('Stencil NextJS Integration', () => {
before(() => browser.url('/'));

/**
* ToDo(Christian): enhance the test by fetching the body response instead of the page source
* which doesn't contain the template contents. This feature is currently not
* available in WebDriver.
*/
it('should have hydrated the page', async () => {
expect(await browser.getPageSource()).toContain(
"<my-input class=\"sc-my-input-h sc-my-input-s\"><input class=\"native-input sc-my-input\" aria-labelledby=\"my-input-1-lbl\" autocapitalize=\"off\" autocomplete=\"off\" autocorrect=\"off\" name=\"my-input-1\" placeholder=\"\" type=\"text\"></my-input><div class=\"inputResult\"><p>Input Event: </p><p>Change Event: </p></div><hr><my-button class=\"button button-solid my-activatable my-focusable\">Click me</my-button>"
)
const source = await browser.getPageSource();
// serializes component children
expect(source).toContain('<input name="myRadioGroup" type="radio" value="one">');
expect(source).toContain('<input name="myRadioGroup" type="radio" value="two">');
expect(source).toContain('<input name="myRadioGroup" type="radio" value="three">');
});

it('should allow to interact with input element', async () => {
Expand Down
15 changes: 5 additions & 10 deletions packages/example-project/next-app/wdio.conf.mts
Original file line number Diff line number Diff line change
@@ -1,19 +1,11 @@
import type { Options } from '@wdio/types';
export const config: Options.Testrunner = {

export const config: WebdriverIO.Config = {
//
// ====================
// Runner Configuration
// ====================
// WebdriverIO supports running e2e tests as well as unit and component tests.
runner: 'local',
autoCompileOpts: {
autoCompile: true,
tsNodeOpts: {
project: './test/tsconfig.json',
transpileOnly: true,
},
},

//
// ==================
// Specify Test Files
Expand Down Expand Up @@ -59,6 +51,9 @@ export const config: Options.Testrunner = {
capabilities: [
{
browserName: 'chrome',
'goog:chromeOptions': {
args: ['--headless'],
},
},
],

Expand Down
2 changes: 2 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 @@ -51,6 +52,7 @@
"dependencies": {
"@lit/react": "^1.0.4",
"html-react-parser": "^5.1.10",
"react-dom": "^18.3.1",
"ts-morph": "^22.0.0"
},
"peerDependenciesMeta": {
Expand Down
151 changes: 111 additions & 40 deletions packages/react-output-target/src/react/ssr.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import type { EventName, ReactWebComponent, WebComponentProps } from '@lit/react';

// A key value map matching React prop names to event names.
Expand Down Expand Up @@ -32,14 +33,6 @@ 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
*/
Expand All @@ -55,52 +48,49 @@ export const createComponentForServerSideRendering = <I extends HTMLElement, E e
stringProps += ` ${key}="${value}"`;
}

const toSerialize = `<${options.tagName}${stringProps}></${options.tagName}>`;
let serializedChildren = '';
const toSerialize = `<${options.tagName}${stringProps} suppressHydrationWarning="true">`;
try {
const awaitedChildren = await resolveComponentTypes(children);
serializedChildren = ReactDOMServer.renderToString(awaitedChildren);
} catch (err: unknown) {
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 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');
}

/**
* cut out the inner content of the component
*/
const templateStartTag = '<template shadowrootmode="open">';
const templateEndTag = '</template>';
const serializedComponentByLine = html.split('\n');
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);
const isShadowComponent = serializedComponentByLine[1].includes('shadowrootmode="open"');
let templateContent: undefined | string = undefined;
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 @@ -124,15 +114,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 +149,71 @@ export const createComponentForServerSideRendering = <I extends HTMLElement, E e
return <StencilElement />;
}) as unknown as ReactWebComponent<I, E>;
};

/**
* resolve the component types for server side rendering
*
* @param children - the children to resolve
* @returns 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<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._payload.type === 'function') {
return type._payload.type();
}
}

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

return newChild;
})
) as Promise<React.ReactNode>;
}
Loading

0 comments on commit 48da093

Please sign in to comment.