diff --git a/examples/ssr-demo/src/pages/index.tsx b/examples/ssr-demo/src/pages/index.tsx index 01b9151f2a85..1c1efec02c6d 100644 --- a/examples/ssr-demo/src/pages/index.tsx +++ b/examples/ssr-demo/src/pages/index.tsx @@ -1,5 +1,10 @@ import React from 'react'; -import { Link, useClientLoaderData, useServerLoaderData } from 'umi'; +import { + Link, + useClientLoaderData, + useServerInsertedHTML, + useServerLoaderData, +} from 'umi'; import Button from '../components/Button'; // @ts-ignore import bigImage from './big_image.jpg'; @@ -14,6 +19,11 @@ import umiLogo from './umi.png'; export default function HomePage() { const clientLoaderData = useClientLoaderData(); const serverLoaderData = useServerLoaderData(); + + useServerInsertedHTML(() => { + return
inserted html
; + }); + return (

Hello~

diff --git a/packages/preset-umi/src/commands/build.ts b/packages/preset-umi/src/commands/build.ts index 140116a5bf60..ce3b69eb2469 100644 --- a/packages/preset-umi/src/commands/build.ts +++ b/packages/preset-umi/src/commands/build.ts @@ -161,6 +161,7 @@ umi build --clean publicPath: api.config.publicPath, }); const { vite } = api.args; + const markupArgs = await getMarkupArgs({ api }); const finalMarkUpArgs = { ...markupArgs, diff --git a/packages/preset-umi/src/features/exportStatic/exportStatic.ts b/packages/preset-umi/src/features/exportStatic/exportStatic.ts index 5c41a5f3787c..426a6dd344c6 100644 --- a/packages/preset-umi/src/features/exportStatic/exportStatic.ts +++ b/packages/preset-umi/src/features/exportStatic/exportStatic.ts @@ -1,11 +1,10 @@ import { getMarkup } from '@umijs/server'; -import { lodash, logger, Mustache, winPath } from '@umijs/utils'; +import { lodash, Mustache, winPath } from '@umijs/utils'; import assert from 'assert'; import { dirname, join, relative } from 'path'; import type { IApi, IRoute } from '../../types'; -import { absServerBuildPath } from '../ssr/utils'; +import { getPreRenderedHTML } from '../ssr/utils'; -let markupRender: any; const IS_WIN = process.platform === 'win32'; interface IExportHtmlItem { @@ -54,39 +53,6 @@ function getExportHtmlData(routes: Record): IExportHtmlItem[] { return Array.from(map.values()); } -/** - * get pre-rendered html by route path - */ -async function getPreRenderedHTML(api: IApi, htmlTpl: string, path: string) { - markupRender ??= require(absServerBuildPath(api))._markupGenerator; - - try { - const markup = await markupRender(path); - const [mainTpl, extraTpl = ''] = markup.split(''); - // TODO: improve return type for markup generator - const helmetContent = mainTpl.match( - /[^]*?(<[^>]+data-rh[^]+)<\/head>/, - )?.[1]; - const bodyContent = mainTpl.match(/]*>([^]+?)<\/body>/)?.[1]; - - htmlTpl = htmlTpl - // append helmet content - .replace('', `${helmetContent || ''}`) - // replace #root with pre-rendered body content - .replace( - new RegExp(`
]*>.*?
`), - bodyContent, - ) - // append hidden templates - .replace(/$/, `${extraTpl}`); - logger.info(`Pre-render for ${path}`); - } catch (err) { - logger.error(`Pre-render ${path} error: ${err}`); - } - - return htmlTpl; -} - export default (api: IApi) => { /** * convert user `exportStatic.extraRoutePaths` config to routes diff --git a/packages/preset-umi/src/features/ssr/ssr.ts b/packages/preset-umi/src/features/ssr/ssr.ts index 65e7e1ee29b3..1d633b930289 100644 --- a/packages/preset-umi/src/features/ssr/ssr.ts +++ b/packages/preset-umi/src/features/ssr/ssr.ts @@ -8,7 +8,7 @@ import assert from 'assert'; import { existsSync, writeFileSync } from 'fs'; import { join } from 'path'; import type { IApi } from '../../types'; -import { absServerBuildPath } from './utils'; +import { absServerBuildPath, getPreRenderedHTML } from './utils'; export default (api: IApi) => { const esbuildBuilder: typeof import('./builder/builder') = importLazy( @@ -18,6 +18,29 @@ export default (api: IApi) => { require.resolve('./webpack/webpack'), ); + // 如果 exportStatic 不存在的时候。预渲染一下 index.html + if (!api.config.exportStatic) { + // export routes to html files + api.modifyExportHTMLFiles(async (_defaultFiles) => { + const preRenderFils: typeof _defaultFiles = []; + for await (const file of _defaultFiles.filter( + (f) => !f.path.includes(':'), + )) { + // 只需要 index.html 就好了,404 copy 一下 index。但是最好还是开一下 exportStatic + if (file.path === 'index.html') { + const html = await getPreRenderedHTML(api, file.content, '/'); + preRenderFils.push({ + path: file.path, + content: html, + }); + } else { + preRenderFils.push(file); + } + } + return preRenderFils; + }); + } + api.describe({ key: 'ssr', config: { @@ -69,6 +92,30 @@ export default (api: IApi) => { content: ` import * as React from 'react'; export { React }; +`, + }); + + api.writeTmpFile({ + noPluginDir: true, + path: 'core/serverInsertedHTMLContext.ts', + content: ` +// Use React.createContext to avoid errors from the RSC checks because +// it can't be imported directly in Server Components: +import React from 'react' + +export type ServerInsertedHTMLHook = (callbacks: () => React.ReactNode) => void; +// More info: https://github.com/vercel/next.js/pull/40686 +export const ServerInsertedHTMLContext = + React.createContext(null as any); + +// copy form https://github.com/vercel/next.js/blob/fa076a3a69c9ccf63c9d1e53e7b681aa6dc23db7/packages/next/src/shared/lib/server-inserted-html.tsx#L13 +export function useServerInsertedHTML(callback: () => React.ReactNode): void { + const addInsertedServerHTMLCallback = React.useContext(ServerInsertedHTMLContext); + // Should have no effects on client where there's no flush effects provider + if (addInsertedServerHTMLCallback) { + addInsertedServerHTMLCallback(callback); + } +} `, }); }); diff --git a/packages/preset-umi/src/features/ssr/utils.ts b/packages/preset-umi/src/features/ssr/utils.ts index 33781aa697a6..7bea14498282 100644 --- a/packages/preset-umi/src/features/ssr/utils.ts +++ b/packages/preset-umi/src/features/ssr/utils.ts @@ -1,3 +1,4 @@ +import { logger } from '@umijs/utils'; import { join } from 'path'; import { IApi } from '../../types'; @@ -25,3 +26,40 @@ export function absServerBuildPath(api: IApi) { api.userConfig.ssr.serverBuildPath || 'server/umi.server.js', ); } + +/** + * get pre-rendered html by route path + */ +export async function getPreRenderedHTML( + api: IApi, + htmlTpl: string, + path: string, +) { + let markupRender; + markupRender ??= require(absServerBuildPath(api))._markupGenerator; + try { + const markup = await markupRender(path); + + const [mainTpl, extraTpl = ''] = markup.split(''); + // TODO: improve return type for markup generator + const helmetContent = + mainTpl.match(/[^]*?(<[^>]+data-rh[^]+)<\/head>/)?.[1] || ''; + const bodyContent = mainTpl.match(/]*>([^]+?)<\/body>/)?.[1]; + + htmlTpl = htmlTpl + // append helmet content + .replace('', `${helmetContent || ''}`) + // replace #root with pre-rendered body content + .replace( + new RegExp(`
]*>.*?
`), + bodyContent, + ) + // append hidden templates + .replace(/$/, `${extraTpl}`); + logger.info(`Pre-render for ${path}`); + } catch (err) { + logger.error(`Pre-render ${path} error: ${err}`); + } + + return htmlTpl; +} diff --git a/packages/preset-umi/src/features/tmpFiles/tmpFiles.ts b/packages/preset-umi/src/features/tmpFiles/tmpFiles.ts index cb30a8ce6802..907cfaeccc75 100644 --- a/packages/preset-umi/src/features/tmpFiles/tmpFiles.ts +++ b/packages/preset-umi/src/features/tmpFiles/tmpFiles.ts @@ -617,6 +617,11 @@ if (process.env.NODE_ENV === 'development') { exports.push(`export { TestBrowser } from './testBrowser';`); } } + if (api.config.ssr && api.appData.framework === 'react') { + exports.push( + `export { useServerInsertedHTML } from './core/serverInsertedHTMLContext';`, + ); + } // plugins exports.push('// plugins'); const allPlugins = readdirSync(api.paths.absTmpPath).filter((file) => diff --git a/packages/preset-umi/templates/server.tpl b/packages/preset-umi/templates/server.tpl index 3478c0944292..3b9581ebced8 100644 --- a/packages/preset-umi/templates/server.tpl +++ b/packages/preset-umi/templates/server.tpl @@ -6,11 +6,18 @@ import { PluginManager } from '{{{ umiPluginPath }}}'; import createRequestHandler, { createMarkupGenerator } from '{{{ umiServerPath }}}'; let helmetContext; +let ServerInsertedHTMLContext; try { helmetContext = require('./core/helmetContext').context; } catch { /* means `helmet: false`, do noting */ } + +try { + ServerInsertedHTMLContext = require('./core/serverInsertedHTMLContext').ServerInsertedHTMLContext; +} catch { /* means `helmet: false`, do noting */ } + + const routesWithServerLoader = { {{#routesWithServerLoader}} '{{{ id }}}': () => import('{{{ path }}}'), @@ -50,6 +57,7 @@ const createOpts = { getClientRootComponent, helmetContext, createHistory, + ServerInsertedHTMLContext, }; const requestHandler = createRequestHandler(createOpts); diff --git a/packages/renderer-react/src/server.tsx b/packages/renderer-react/src/server.tsx index 789f6605f037..f1fbcd96805c 100644 --- a/packages/renderer-react/src/server.tsx +++ b/packages/renderer-react/src/server.tsx @@ -63,6 +63,7 @@ export async function getClientRootComponent(opts: { function Html({ children, loaderData, manifest }: any) { // TODO: 处理 head 标签,比如 favicon.ico 的一致性 // TODO: root 支持配置 + return ( @@ -78,6 +79,7 @@ function Html({ children, loaderData, manifest }: any) { __html: `Enable JavaScript to run this app.`, }} /> +
{children}