Skip to content

Commit

Permalink
Add new package with renderToMarkup export (#30105)
Browse files Browse the repository at this point in the history
Name of the package is tbd (straw: `react-html`). It's a new package
separate from `react-dom` though and can be used as a standalone package
- e.g. also from a React Native app.

```js
import {renderToMarkup} from '...';
const html = await renderToMarkup(<Component />);
```

The idea is that this is a helper for rendering HTML that is not
intended to be hydrated. It's primarily intended to support a subset of
HTML that can be used as embedding and not served as HTML documents from
HTTP. For example as e-mails or in RSS/Atom feeds or other
distributions. It's a successor to `renderToStaticMarkup`.

A few differences:

- This doesn't support "Client Components". It can only use the Server
Components subset. No useEffect, no useState etc. since it will never be
hydrated. Use of those are errors.
- You also can't pass Client References so you can't use components
marked with `"use client"`.
- Unlike `renderToStaticMarkup` this does support async so you can
suspend and use data from these components.
- Unlike `renderToReadableStream` this does not support streaming or
Suspense boundaries and any error rejects the promise. Since there's no
feasible way to "client render" or patch up the document.
- Form Actions are not supported since in an embedded environment
there's no place to post back to across versions. You can render plain
forms with fixed URLs though.
- You can't use any resource preloading like `preload()` from
`react-dom`.

## Implementation

This first version in this PR only supports Server Components since
that's the thing that doesn't have an existing API. Might add a Client
Components version later that errors.

We don't want to maintain a completely separate implementation for this
use case so this uses the `dom-legacy` build dimension to wire up a
build that encapsulates a Flight Server -> Flight Client -> Fizz stream
to render Server Components that then get SSR:ed.

There's no problem to use a Flight Client in a Server Component
environment since it's already supported for Server-to-Server. Both of
these use a bundler config that just errors for Client References though
since we don't need any bundling integration and this is just a
standalone package.

Running Fizz in a Server Component environment is a problem though
because it depends on "react" and it needs the client version.
Therefore, for this build we embed the client version of "react" shared
internals into the build. It doesn't need anything to be able to use
those APIs since you can't call the client APIs anyway.

One unfortunate thing though is that since Flight currently needs to go
to binary and back, we need TextEncoder/TextDecoder to be available but
this shouldn't really be necessary. Also since we use the legacy stream
config, large strings that use byteLengthOfChunk errors atm. This needs
to be fixed before shipping. I'm not sure what would be the best
layering though that isn't unnecessarily burdensome to maintain. Maybe
some kind of pass-through protocol that would also be useful in general
- e.g. when Fizz and Flight are in the same process.

---------

Co-authored-by: Sebastian Silbermann <silbermann.sebastian@gmail.com>
  • Loading branch information
sebmarkbage and eps1lon authored Jun 27, 2024
1 parent 3bee073 commit ffec9ec
Show file tree
Hide file tree
Showing 16 changed files with 627 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ export * from 'react-client/src/ReactFlightClientStreamConfigWeb';
export * from 'react-client/src/ReactClientConsoleConfigPlain';
export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM';

export type Response = any;
export opaque type ModuleLoading = mixed;
export opaque type SSRModuleMap = mixed;
export opaque type ServerManifest = mixed;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,82 @@
* @flow
*/

export * from 'react-client/src/ReactFlightClientStreamConfigWeb';
export * from 'react-client/src/ReactClientConsoleConfigBrowser';
export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM';

export type Response = any;
export opaque type ModuleLoading = mixed;
export opaque type SSRModuleMap = mixed;
export opaque type ServerManifest = mixed;
import type {Thenable} from 'shared/ReactTypes';

export * from 'react-html/src/ReactHTMLLegacyClientStreamConfig.js';
export * from 'react-client/src/ReactClientConsoleConfigPlain';

export type ModuleLoading = null;
export type SSRModuleMap = null;
export opaque type ServerManifest = null;
export opaque type ServerReferenceId = string;
export opaque type ClientReferenceMetadata = mixed;
export opaque type ClientReference<T> = mixed; // eslint-disable-line no-unused-vars
export const resolveClientReference: any = null;
export const resolveServerReference: any = null;
export const preloadModule: any = null;
export const requireModule: any = null;
export const prepareDestinationForModule: any = null;
export opaque type ClientReferenceMetadata = null;
export opaque type ClientReference<T> = null; // eslint-disable-line no-unused-vars

export function prepareDestinationForModule(
moduleLoading: ModuleLoading,
nonce: ?string,
metadata: ClientReferenceMetadata,
) {
throw new Error(
'renderToMarkup should not have emitted Client References. This is a bug in React.',
);
}

export function resolveClientReference<T>(
bundlerConfig: SSRModuleMap,
metadata: ClientReferenceMetadata,
): ClientReference<T> {
throw new Error(
'renderToMarkup should not have emitted Client References. This is a bug in React.',
);
}

export function resolveServerReference<T>(
config: ServerManifest,
id: ServerReferenceId,
): ClientReference<T> {
throw new Error(
'renderToMarkup should not have emitted Server References. This is a bug in React.',
);
}

export function preloadModule<T>(
metadata: ClientReference<T>,
): null | Thenable<T> {
return null;
}

export function requireModule<T>(metadata: ClientReference<T>): T {
throw new Error(
'renderToMarkup should not have emitted Client References. This is a bug in React.',
);
}

export const usedWithSSR = true;

type HintCode = string;
type HintModel<T: HintCode> = null; // eslint-disable-line no-unused-vars

export function dispatchHint<Code: HintCode>(
code: Code,
model: HintModel<Code>,
): void {
// Should never happen.
}

export function preinitModuleForSSR(
href: string,
nonce: ?string,
crossOrigin: ?string,
) {
// Should never happen.
}

export function preinitScriptForSSR(
href: string,
nonce: ?string,
crossOrigin: ?string,
) {
// Should never happen.
}
32 changes: 32 additions & 0 deletions packages/react-html/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# `react-html`

This package provides the ability to render standalone HTML from Server Components for use in embedded contexts such as e-mails and RSS/Atom feeds. It cannot use Client Components and does not hydrate. It is intended to be paired with the generic React package, which is shipped as `react` to npm.

## Installation

```sh
npm install react react-html
```

## Usage

```js
import { renderToMarkup } from 'react-html';
import EmailTemplate from './my-email-template-component.js'

async function action(email, name) {
"use server";
// ... in your server, e.g. a Server Action...
const htmlString = await renderToMarkup(<EmailTemplate name={name} />);
// ... send e-mail using some e-mail provider
await sendEmail({ to: email, contentType: 'text/html', body: htmlString });
}
```

Note that this is an async function that needs to be awaited - unlike the legacy `renderToString` in `react-dom`.

## API

### `react-html`

See https://react.dev/reference/react-html
5 changes: 5 additions & 0 deletions packages/react-html/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
'use strict';

throw new Error(
'react-html is not supported outside a React Server Components environment.',
);
5 changes: 5 additions & 0 deletions packages/react-html/npm/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
'use strict';

throw new Error(
'react-html is not supported outside a React Server Components environment.'
);
7 changes: 7 additions & 0 deletions packages/react-html/npm/react-html.react-server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use strict';

if (process.env.NODE_ENV === 'production') {
module.exports = require('./cjs/react-html.react-server.production.js');
} else {
module.exports = require('./cjs/react-html.react-server.development.js');
}
38 changes: 38 additions & 0 deletions packages/react-html/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "react-html",
"version": "19.0.0",
"private": true,
"description": "React package generating embedded HTML markup such as e-mails using Server Components.",
"main": "index.js",
"repository": {
"type": "git",
"url": "https://github.com/facebook/react.git",
"directory": "packages/react-html"
},
"keywords": [
"react"
],
"license": "MIT",
"bugs": {
"url": "https://github.com/facebook/react/issues"
},
"homepage": "https://react.dev/",
"peerDependencies": {
"react": "^19.0.0"
},
"files": [
"LICENSE",
"README.md",
"index.js",
"react-html.react-server.js",
"cjs/"
],
"exports": {
".": {
"react-server": "./react-html.react-server.js",
"default": "./index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
}
}
10 changes: 10 additions & 0 deletions packages/react-html/react-html.react-server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/

export * from './src/ReactHTMLServer';
32 changes: 32 additions & 0 deletions packages/react-html/src/ReactHTMLLegacyClientStreamConfig.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/

// TODO: The legacy one should not use binary.

export type StringDecoder = TextDecoder;

export function createStringDecoder(): StringDecoder {
return new TextDecoder();
}

const decoderOptions = {stream: true};

export function readPartialStringChunk(
decoder: StringDecoder,
buffer: Uint8Array,
): string {
return decoder.decode(buffer, decoderOptions);
}

export function readFinalStringChunk(
decoder: StringDecoder,
buffer: Uint8Array,
): string {
return decoder.decode(buffer);
}
Loading

0 comments on commit ffec9ec

Please sign in to comment.