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

fix: metadata and hydrate root mismatched between csr and ssr #12220

Merged
merged 15 commits into from
Apr 1, 2024
Merged
21 changes: 20 additions & 1 deletion examples/ssr-demo/.umirc.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,29 @@
export default {
svgr: {},
hash: true,
mfsu: false,
PeachScript marked this conversation as resolved.
Show resolved Hide resolved
routePrefetch: {},
manifest: {},
clientLoader: {},
title: '测试title',
scripts: [`https://a.com/b.js`],
ssr: {
serverBuildPath: './umi.server.js',
builder: 'webpack',
renderFromRoot: false,
},
styles: [`body { color: red; }`, `https://a.com/b.css`],

metas: [
{
name: 'test',
content: 'content',
},
],
links: [{ href: '/foo.css', rel: 'preload' }],

headScripts: [
{
src: 'https://www.baidu.com',
fz6m marked this conversation as resolved.
Show resolved Hide resolved
},
],
};
5 changes: 4 additions & 1 deletion packages/preset-umi/src/commands/dev/getBabelOpts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { IApi } from '../../types';

export async function getBabelOpts(opts: { api: IApi }) {
// TODO: 支持用户自定义
const shouldUseAutomaticRuntime = semver.gte(opts.api.appData.react.version, '16.14.0');
const shouldUseAutomaticRuntime = semver.gte(
opts.api.appData.react.version,
'16.14.0',
);
const babelPresetOpts = await opts.api.applyPlugins({
key: 'modifyBabelPresetOpts',
initialValue: {
Expand Down
1 change: 1 addition & 0 deletions packages/preset-umi/src/features/ssr/ssr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export default (api: IApi) => {
serverBuildPath: zod.string(),
platform: zod.string(),
builder: zod.enum(['esbuild', 'webpack']),
renderFromRoot: zod.boolean(),
})
.deepPartial();
},
Expand Down
14 changes: 13 additions & 1 deletion packages/preset-umi/src/features/tmpFiles/tmpFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { importLazy, lodash, winPath } from '@umijs/utils';
import { existsSync, readdirSync } from 'fs';
import { basename, dirname, join } from 'path';
import { RUNTIME_TYPE_FILE_NAME } from 'umi';
import { getMarkupArgs } from '../../commands/dev/getMarkupArgs';
import { TEMPLATES_DIR } from '../../constants';
import { IApi } from '../../types';
import { getModuleExports } from './getModuleExports';
import { importsToStr } from './importsToStr';

const routesApi: typeof import('./routes') = importLazy(
require.resolve('./routes'),
);
Expand Down Expand Up @@ -496,6 +496,8 @@ if (process.env.NODE_ENV === 'development') {
}
return memo;
}, []);
const { headScripts, scripts, styles, title, favicons, links, metas } =
await getMarkupArgs({ api });
api.writeTmpFile({
noPluginDir: true,
path: 'umi.server.ts',
Expand All @@ -514,6 +516,16 @@ if (process.env.NODE_ENV === 'development') {
join(api.paths.absOutputPath, 'build-manifest.json'),
),
env: JSON.stringify(api.env),
metadata: JSON.stringify({
headScripts,
styles,
title,
favicons,
links,
metas,
scripts: scripts || [],
}),
renderFromRoot: api.config.ssr?.renderFromRoot ?? false,
},
});
}
Expand Down
3 changes: 3 additions & 0 deletions packages/preset-umi/templates/server.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ const createOpts = {
helmetContext,
createHistory,
ServerInsertedHTMLContext,
metadata: {{{metadata}}},
renderFromRoot: {{{renderFromRoot}}}

};
const requestHandler = createRequestHandler(createOpts);
/**
Expand Down
21 changes: 18 additions & 3 deletions packages/renderer-react/src/browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import ReactDOM from 'react-dom/client';
import { matchRoutes, Router, useRoutes } from 'react-router-dom';
import { AppContext, useAppData } from './appContext';
import { fetchServerLoader } from './dataFetcher';
import { Html } from './html';
import { createClientRoutes } from './routes';
import { ILoaderData, IRouteComponents, IRoutesById } from './types';

let root: ReactDOM.Root | null = null;

// react 18 some scenarios need unmount such as micro app
Expand Down Expand Up @@ -96,6 +96,11 @@ export type RenderClientOpts = {
* @doc 一般不需要改,微前端的时候会变化
*/
rootElement?: HTMLElement;
/**
* ssr 是否从 app root 根节点开始 render
* @doc 默认 false, 从 app root 开始 render,为 true 时从 html 开始
*/
renderFromRoot?: boolean;
/**
* 当前的路由配置
*/
Expand Down Expand Up @@ -331,12 +336,22 @@ const getBrowser = (
*/
export function renderClient(opts: RenderClientOpts) {
const rootElement = opts.rootElement || document.getElementById('root')!;

const Browser = getBrowser(opts, <Routes />);
// 为了测试,直接返回组件
if (opts.components) return Browser;

if (opts.hydrate) {
ReactDOM.hydrateRoot(rootElement, <Browser />);
// @ts-ignore
const loaderData = window.__UMI_LOADER_DATA__ || {};
// @ts-ignore
const metadata = window.__UMI_METADATA_LOADER_DATA__ || {};

ReactDOM.hydrateRoot(
document,
PeachScript marked this conversation as resolved.
Show resolved Hide resolved
<Html {...{ metadata, loaderData }}>
<Browser />
</Html>,
);
return;
}

Expand Down
137 changes: 137 additions & 0 deletions packages/renderer-react/src/html.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import React from 'react';
import { IHtmlProps, IScript } from './types';

const RE_URL = /^(http:|https:)?\/\//;

function isUrl(str: string) {
return (
RE_URL.test(str) ||
(str.startsWith('/') && !str.startsWith('/*')) ||
str.startsWith('./') ||
str.startsWith('../')
);
}

function normalizeScripts(script: IScript, extraProps = {}) {
if (typeof script === 'string') {
return isUrl(script)
? {
src: script,
...extraProps,
}
: { content: script };
} else if (typeof script === 'object') {
return {
...script,
...extraProps,
};
} else {
throw new Error(`Invalid script type: ${typeof script}`);
}
}

function generatorStyle(style: string) {
return isUrl(style)
? { type: 'link', href: style }
: { type: 'style', content: style };
}
fz6m marked this conversation as resolved.
Show resolved Hide resolved

const NormalizeMetadata = (props: IHtmlProps) => {
const { metadata } = props;
return (
<>
{metadata?.title && <title>{metadata.title}</title>}
{metadata?.favicons?.map((favicon: string, key: number) => {
return <link key={key} rel="shortcut icon" href={favicon} />;
})}
{metadata?.description && (
<meta name="description" content={metadata.description} />
)}
{metadata?.keywords?.length && (
<meta name="keywords" content={metadata.keywords.join(',')} />
)}
{metadata?.metas?.map((em: any) => (
<meta key={em.name} name={em.name} content={em.content} />
))}

{metadata?.links?.map((link: Record<string, string>, key: number) => {
Copy link
Member

Choose a reason for hiding this comment

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

后续限制一下,不允许通过 metadataLoader 设置非 tdk 的配置

return <link key={key} {...link} />;
})}
{metadata?.styles?.map((style: string, key: number) => {
const { type, href, content } = generatorStyle(style);
if (type === 'link') {
return <link key={key} rel="stylesheet" href={href} />;
} else if (type === 'style') {
return <style key={key}>{content}</style>;
}
})}
{metadata?.headScripts?.map((script: IScript, key: number) => {
const { content, ...rest } = normalizeScripts(script);
return (
<script key={key} {...(rest as any)}>
{content}
</script>
);
})}
</>
);
};

export function Html({
children,
loaderData,
manifest,
metadata,
renderFromRoot,
}: React.PropsWithChildren<IHtmlProps>) {
// TODO: 处理 head 标签,比如 favicon.ico 的一致性
Copy link
Member

Choose a reason for hiding this comment

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

后续删掉 TODO

// TODO: root 支持配置

if (renderFromRoot) {
return (
<>
<NormalizeMetadata metadata={metadata} />
<div id="root">{children}</div>
</>
);
}
return (
<html lang={metadata?.lang || 'en'}>
Copy link
Contributor

Choose a reason for hiding this comment

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

lang 不能用 metadata 的配置了,客户端没有,水合会报错

Copy link
Member

Choose a reason for hiding this comment

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

线下讨论:Umi 加 htmlLang: string or lang: string 静态配置,不允许通过 metadataLoader 来改,后续调研下 Next.js 的做法,因为只是 warning 暂时不急

<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{manifest?.assets['umi.css'] && (
<link rel="stylesheet" href={manifest?.assets['umi.css']} />
)}
<NormalizeMetadata metadata={metadata} />
</head>
<body>
<noscript
dangerouslySetInnerHTML={{
__html: `<b>Enable JavaScript to run this app.</b>`,
}}
/>

<div id="root">{children}</div>
<script
dangerouslySetInnerHTML={{
__html: `window.__UMI_LOADER_DATA__ = ${JSON.stringify(
loaderData || {},
)}; window.__UMI_METADATA_LOADER_DATA__ = ${JSON.stringify(
metadata,
)}`,
}}
/>

{metadata?.scripts?.map((script: IScript, key: number) => {
const { content, ...rest } = normalizeScripts(script);
return (
<script key={key} {...(rest as any)}>
{content}
</script>
);
})}
</body>
</html>
);
}
64 changes: 3 additions & 61 deletions packages/renderer-react/src/server.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,13 @@
import type { IMetadata } from '@umijs/server/dist/types';
import React from 'react';
import { StaticRouter } from 'react-router-dom/server';
import { AppContext } from './appContext';
import { Routes } from './browser';
import { Html } from './html';
import { createClientRoutes } from './routes';
import { IRouteComponents, IRoutesById } from './types';

interface IHtmlProps {
routes: IRoutesById;
routeComponents: IRouteComponents;
pluginManager: any;
location: string;
loaderData: { [routeKey: string]: any };
manifest: any;
metadata?: IMetadata;
}
import { IRootComponentOptions } from './types';

// Get the root React component for ReactDOMServer.renderToString
export async function getClientRootComponent(opts: IHtmlProps) {
export async function getClientRootComponent(opts: IRootComponentOptions) {
const basename = '/';
const components = { ...opts.routeComponents };
const clientRoutes = createClientRoutes({
Expand Down Expand Up @@ -62,51 +52,3 @@ export async function getClientRootComponent(opts: IHtmlProps) {
);
return <Html {...opts}>{app}</Html>;
}

function Html({
children,
loaderData,
manifest,
metadata,
}: React.PropsWithChildren<IHtmlProps>) {
// TODO: 处理 head 标签,比如 favicon.ico 的一致性
// TODO: root 支持配置

return (
<html lang={metadata?.lang || 'en'}>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{metadata?.title && <title>{metadata.title}</title>}
{metadata?.description && (
<meta name="description" content={metadata.description} />
)}
{metadata?.keywords?.length && (
<meta name="keywords" content={metadata.keywords.join(',')} />
)}
{metadata?.metas?.map((em) => (
<meta key={em.name} name={em.name} content={em.content} />
))}
{manifest.assets['umi.css'] && (
<link rel="stylesheet" href={manifest.assets['umi.css']} />
)}
</head>
<body>
<noscript
dangerouslySetInnerHTML={{
__html: `<b>Enable JavaScript to run this app.</b>`,
}}
/>

<div id="root">{children}</div>
<script
dangerouslySetInnerHTML={{
__html: `window.__UMI_LOADER_DATA__ = ${JSON.stringify(
loaderData,
)}`,
}}
/>
</body>
</html>
);
}
Loading
Loading