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',
hydrateFromRoot: 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
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ export default (api: IApi) => {
},
];
}

return [];
},
stage: Infinity,
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']),
hydrateFromRoot: 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 || [],
}),
hydrateFromRoot: api.config.ssr?.hydrateFromRoot ?? false,
},
});
}
Expand Down
9 changes: 9 additions & 0 deletions packages/preset-umi/templates/server.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,18 @@ const createOpts = {
helmetContext,
createHistory,
ServerInsertedHTMLContext,
metadata: {{{metadata}}},
hydrateFromRoot: {{{hydrateFromRoot}}}

};
const requestHandler = createRequestHandler(createOpts);
/**
* @deprecated Please use `requestHandler` instead.
*/
export const renderRoot = createUmiHandler(createOpts);
/**
* @deprecated Please use `requestHandler` instead.
*/
export const serverLoader = createUmiServerLoader(createOpts);

export const _markupGenerator = createMarkupGenerator(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 根节点开始 hydrate
* @doc 默认 false, 从 app root 开始水合,为 true 时从 html 开始
*/
hydrateFromRoot?: 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
118 changes: 118 additions & 0 deletions packages/renderer-react/src/html.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
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

export function Html({
children,
loaderData,
manifest,
metadata,
}: 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 支持配置
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" />
{metadata?.title && <title>{metadata.title}</title>}
{metadata?.favicons?.map((favicon: string, key: number) => {
return <link key={key} rel="shortcut icon" href={favicon} />;
Copy link
Contributor

Choose a reason for hiding this comment

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

需要考虑 publicPath 的问题,参考 packages/preset-umi/src/features/favicons/favicons.ts ,最终得到的 favicons 的 html element 其实是有 publicPath 的。

Copy link
Member

Choose a reason for hiding this comment

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

线下讨论:改成从 getMarkupArgs 获取模板参数,这样就和非 SSR 逻辑一致了

})}
{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) => {
return <link key={key} {...link} />;
Copy link
Contributor

Choose a reason for hiding this comment

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

属性的添加方式,需要和非 ssr 保持一致,需要规范化,可参考 packages/server/src/server.ts

})}
{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>
);
})}
{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 || {},
)}; 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>
);
}
70 changes: 8 additions & 62 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 @@ -60,53 +50,9 @@ export async function getClientRootComponent(opts: IHtmlProps) {
{rootContainer}
</AppContext.Provider>
);
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>
);
if (!opts.hydrateFromRoot) {
return <Html {...opts}>{app}</Html>;
} else {
return app;
}
PeachScript marked this conversation as resolved.
Show resolved Hide resolved
}
Loading
Loading