Skip to content
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
34 changes: 34 additions & 0 deletions packages/react/src/runtime/ssr.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,40 @@ describe('SSR Boolean attributes & shadowrootdelegatesfocus', () => {
).resolves.toBeDefined();
});

it('should serialize a nested Stencil SSR child shallowly instead of rendering it', async () => {
// The parent's light DOM pass must not run the child's hydrate render;
// that doubled the cost at every nesting level.
const { renderToString } = await import('react-dom/server');
const realRenderToString = (await vi.importActual<typeof import('react-dom/server')>('react-dom/server'))
.renderToString;
vi.mocked(renderToString).mockImplementationOnce((element) => realRenderToString(element as React.ReactElement));

const ParentComponent = createComponent({
tagName: 'my-button',
properties: {},
hydrateModule: Promise.resolve(mockHydrateModule),
});
const ChildComponent = createComponent({
tagName: 'my-component',
properties: { iconName: 'icon-name' },
hydrateModule: Promise.resolve(mockHydrateModule),
});

await ParentComponent({
children: React.createElement(
ChildComponent as any,
{ slot: 'start', iconName: 'home', disabled: false },
'Label'
),
});

// Only the parent went through the hydrate module.
expect(capturedRenderToStringArgs).toHaveLength(1);
const [lightDom] = capturedRenderToStringArgs[0];
expect(lightDom).toContain('<my-component slot="start" icon-name="home">Label</my-component>');
expect(lightDom).not.toContain('<template');
});

it('should resolve deeply nested async Stencil SSR grandchild without error', async () => {
const grandchildOptions: CreateComponentForSSROptions<HTMLElement> = {
tagName: 'my-counter',
Expand Down
87 changes: 62 additions & 25 deletions packages/react/src/runtime/ssr.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,54 @@ interface CreateComponentForServerSideRenderingOptions {
transformTag?: (tagName: string) => string;
}

// Marks SSR components so `resolveType` can serialize nested ones shallowly.
const stencilSsrOptions = Symbol('stencil-react-ssr-options');
type ShallowComponentOptions = {
tagName: string;
properties: Record<string, string>;
transformTag?: (tag: string) => string;
};
type MarkedSsrComponent = { [stencilSsrOptions]: ShallowComponentOptions };

type AttributeValue = string | number | boolean | Record<string, unknown>;

// Splits props into attribute values (primitives, `style`) keyed by attribute name and
// complex values applied as properties via `beforeHydrate`. `false` is dropped.
const splitProps = (props: Record<string, unknown>, properties: Record<string, string>) => {
const attributes: Record<string, AttributeValue> = {};
const complexProps: Record<string, unknown> = {};
for (const [key, value] of Object.entries(props)) {
if (typeof value === 'boolean' && value === false) {
continue;
}
const attributeName = possibleStandardNames[key as keyof typeof possibleStandardNames] || properties[key] || key;
if (key === 'style' && typeof value === 'object' && value) {
attributes[attributeName] = value as Record<string, unknown>;
continue;
}
if (isPrimitive(value)) {
attributes[attributeName] = value;
} else {
complexProps[key] = value;
}
}
return { attributes, complexProps };
};

const isStencilSsrComponent = (type: unknown): type is MarkedSsrComponent =>
typeof type === 'function' && stencilSsrOptions in type;

// Parents only need nested Stencil children as tags with attributes. Rendering them fully
// here ran each child once per ancestor level; the child renders itself once as a real child.
const createShallowComponent =
(options: ShallowComponentOptions) =>
({ children, ...props }: { children?: ReactNode } & Record<string, unknown>) => {
const { attributes } = splitProps(props, options.properties);
const transformTag = options.transformTag ?? hydrateModuleCache?.transformTag;
const tagName = transformTag ? transformTag(options.tagName) : options.tagName;
return React.createElement(tagName, { ...attributes, suppressHydrationWarning: true }, children);
};

type StencilProps<I extends HTMLElement> = WebComponentProps<I>;

// Definition comes from React but is not exported or part of the types package
Expand Down Expand Up @@ -178,30 +226,11 @@ const createComponentForServerSideRendering = <I extends HTMLElement, E extends
* compose element props into a string; complex (non-primitive) props are banked
* and applied via beforeHydrate; they never need to be attribute-serialized
*/
const { attributes, complexProps } = splitProps(props, options.properties);
let stringProps = '';
const complexProps: Record<string, unknown> = {};
for (const [key, value] of Object.entries(props)) {
if (typeof value === 'boolean' && value === false) {
continue;
}

/**
* parse the style object into a string
*/
if (key === 'style' && typeof value === 'object' && value) {
const propName =
possibleStandardNames[key as keyof typeof possibleStandardNames] || options.properties[key] || key;
stringProps += ` ${propName}="${stringifyCSSProperties(value)}"`;
continue;
}

if (isPrimitive(value)) {
const propName =
possibleStandardNames[key as keyof typeof possibleStandardNames] || options.properties[key] || key;
stringProps += ` ${propName}="${value}"`;
} else {
complexProps[key] = value;
}
for (const [attributeName, value] of Object.entries(attributes)) {
const serialized = typeof value === 'object' ? stringifyCSSProperties(value) : value;
stringProps += ` ${attributeName}="${serialized}"`;
}

/**
Expand Down Expand Up @@ -449,6 +478,11 @@ async function resolveComponentTypes(children: ReactNode): Promise<ReactNode> {
const resolveType = async (type: string | React.JSXElementConstructor<any>, props: any): Promise<ReactNodeExtended> => {
let resolvedType: ReactNodeExtended = null;

if (isStencilSsrComponent(type)) {
// Nested Stencil SSR component: emit its tag shallowly instead of running its hydrate pass.
return createShallowComponent(type[stencilSsrOptions]);
}

if (typeof type === 'string') {
// Child is a primitive element like 'div'
return type;
Expand Down Expand Up @@ -548,7 +582,7 @@ export const createComponent = <
* Note: we want to lazy load the `./ssr` and `hydrateModule` modules to avoid
* bundling them in the runtime and serving them in the browser.
*/
return (async (props: WebComponentProps<I>) => {
const ServerComponent = (async (props: WebComponentProps<I>) => {
if (!options.hydrateModule) {
throw new Error(
'`hydrateModule` is required when rendering a Stencil component on the server. ' +
Expand All @@ -574,5 +608,8 @@ export const createComponent = <
renderToString: resolvedHydrateModule.renderToString,
...options,
})(props as any);
}) as unknown as StencilReactComponent<I, E, C, R>;
}) as unknown as StencilReactComponent<I, E, C, R> & MarkedSsrComponent;
ServerComponent[stencilSsrOptions] = options;

return ServerComponent;
};
Loading