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

feat(ssr): support useServerInsertedHTML #11227

Merged
merged 5 commits into from
Jun 21, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
12 changes: 11 additions & 1 deletion examples/ssr-demo/src/pages/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,6 +19,11 @@ import umiLogo from './umi.png';
export default function HomePage() {
const clientLoaderData = useClientLoaderData();
const serverLoaderData = useServerLoaderData();

useServerInsertedHTML(() => {
return <div>inserted html</div>;
});

return (
<div>
<h1 className="title">Hello~</h1>
Expand Down
24 changes: 24 additions & 0 deletions packages/preset-umi/src/features/ssr/ssr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,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<ServerInsertedHTMLHook | null>(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);
}
}
`,
});
});
Expand Down
5 changes: 5 additions & 0 deletions packages/preset-umi/src/features/tmpFiles/tmpFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
8 changes: 8 additions & 0 deletions packages/preset-umi/templates/server.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 */ }
chenshuai2144 marked this conversation as resolved.
Show resolved Hide resolved


const routesWithServerLoader = {
{{#routesWithServerLoader}}
'{{{ id }}}': () => import('{{{ path }}}'),
Expand Down Expand Up @@ -50,6 +57,7 @@ const createOpts = {
getClientRootComponent,
helmetContext,
createHistory,
ServerInsertedHTMLContext,
};
const requestHandler = createRequestHandler(createOpts);

Expand Down
2 changes: 2 additions & 0 deletions packages/renderer-react/src/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export async function getClientRootComponent(opts: {
function Html({ children, loaderData, manifest }: any) {
// TODO: 处理 head 标签,比如 favicon.ico 的一致性
// TODO: root 支持配置

return (
<html lang="en">
<head>
Expand All @@ -78,6 +79,7 @@ function Html({ children, loaderData, manifest }: any) {
__html: `<b>Enable JavaScript to run this app.</b>`,
}}
/>

<div id="root">{children}</div>
<script
dangerouslySetInnerHTML={{
Expand Down
89 changes: 75 additions & 14 deletions packages/server/src/ssr.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ReactElement } from 'react';
import { renderToPipeableStream } from 'react-dom/server';
import React, { ReactElement } from 'react';
import * as ReactDomServer from 'react-dom/server';
import { matchRoutes } from 'react-router-dom';
import { Writable } from 'stream';
import type { IRoutesById } from './types';
Expand All @@ -8,6 +8,8 @@ interface RouteLoaders {
[key: string]: () => Promise<any>;
}

export type ServerInsertedHTMLHook = (callbacks: () => React.ReactNode) => void;

interface CreateRequestHandlerOptions {
routesWithServerLoader: RouteLoaders;
PluginManager: any;
Expand All @@ -20,8 +22,11 @@ interface CreateRequestHandlerOptions {
getClientRootComponent: (PluginManager: any) => any;
createHistory: (opts: any) => any;
helmetContext?: any;
ServerInsertedHTMLContext: React.Context<ServerInsertedHTMLHook | null>;
}

const serverInsertedHTMLCallbacks: Set<() => React.ReactNode> = new Set();

function createJSXGenerator(opts: CreateRequestHandlerOptions) {
return async (url: string) => {
const {
Expand Down Expand Up @@ -81,19 +86,61 @@ function createJSXGenerator(opts: CreateRequestHandlerOptions) {
loaderData,
};

const element = (await opts.getClientRootComponent(
context,
)) as ReactElement;

const JSXProvider = (props: any) => {
const addInsertedHtml = React.useCallback(
(handler: () => React.ReactNode) => {
serverInsertedHTMLCallbacks.add(handler);
},
[],
);

return React.createElement(opts.ServerInsertedHTMLContext.Provider, {
children: props.children,
value: addInsertedHtml,
});
};

return {
element: (await opts.getClientRootComponent(context)) as ReactElement,
element: React.createElement(JSXProvider, { children: element }),
manifest,
};
};
}

const getGenerateStaticHTML = () => {
return (
ReactDomServer.renderToString(
React.createElement(React.Fragment, {
children: Array.from(serverInsertedHTMLCallbacks).map((callback) =>
callback(),
),
}),
) || ''
);
};

export function createMarkupGenerator(opts: CreateRequestHandlerOptions) {
const jsxGeneratorDeferrer = createJSXGenerator(opts);
const JSXProvider = (props: any) => {
const addInsertedHtml = React.useCallback(
chenshuai2144 marked this conversation as resolved.
Show resolved Hide resolved
(handler: () => React.ReactNode) => {
serverInsertedHTMLCallbacks.add(handler);
},
[],
);

return React.createElement(opts.ServerInsertedHTMLContext.Provider, {
children: props.children,
value: addInsertedHtml,
});
};

return async (url: string) => {
const jsx = await jsxGeneratorDeferrer(url);

if (jsx) {
return new Promise(async (resolve, reject) => {
let chunks: Buffer[] = [];
Expand All @@ -103,9 +150,9 @@ export function createMarkupGenerator(opts: CreateRequestHandlerOptions) {
chunks.push(Buffer.from(chunk));
next();
};
writable.on('finish', () => {
writable.on('finish', async () => {
let html = Buffer.concat(chunks).toString('utf8');

html += await getGenerateStaticHTML();
// append helmet tags to head
if (opts.helmetContext) {
html = html.replace(
Expand All @@ -128,12 +175,15 @@ export function createMarkupGenerator(opts: CreateRequestHandlerOptions) {

// why not use `renderToStaticMarkup` or `renderToString`?
// they will return empty root by unknown reason (maybe umi has suspense logic?)
const stream = renderToPipeableStream(jsx.element, {
onShellReady() {
stream.pipe(writable);
const stream = ReactDomServer.renderToPipeableStream(
React.createElement(JSXProvider, { children: jsx.element }),
{
onShellReady() {
stream.pipe(writable);
},
onError: reject,
},
onError: reject,
});
);
});
}

Expand Down Expand Up @@ -161,11 +211,22 @@ export default function createRequestHandler(

if (!jsx) return next();

const stream = renderToPipeableStream(jsx.element, {
const writable = new Writable();

writable._write = (chunk, _encoding, next) => {
res.write(chunk);
next();
chenshuai2144 marked this conversation as resolved.
Show resolved Hide resolved
};

writable.on('finish', async () => {
res.write(await getGenerateStaticHTML());
res.end();
});

const stream = await ReactDomServer.renderToPipeableStream(jsx.element, {
bootstrapScripts: [jsx.manifest.assets['umi.js'] || '/umi.js'],
onShellReady() {
res.setHeader('Content-type', 'text/html');
stream.pipe(res);
stream.pipe(writable);
},
onError(x: any) {
console.error(x);
Expand Down
Loading