Skip to content

Commit bd45ad0

Browse files
authored
Add a DOCTYPE to the stream if the <html> tag is rendered (#21680)
This makes it a lot easier to render the whole document using React without needing to patch into the stream. We expect that currently people will still have to patch into the stream to do advanced things but eventually the goal is that you shouldn't need to.
1 parent a8f5e77 commit bd45ad0

File tree

9 files changed

+80
-13
lines changed

9 files changed

+80
-13
lines changed

fixtures/fizz-ssr-browser/index.html

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,14 @@ <h1>Fizz Example</h1>
2121
<script src="https://unpkg.com/babel-standalone@6/babel.js"></script>
2222
<script type="text/babel">
2323
let controller = new AbortController();
24-
let stream = ReactDOMFizzServer.renderToReadableStream(<body>Success</body>, {
25-
signal: controller.signal,
26-
});
24+
let stream = ReactDOMFizzServer.renderToReadableStream(
25+
<html>
26+
<body>Success</body>
27+
</html>,
28+
{
29+
signal: controller.signal,
30+
}
31+
);
2732
let response = new Response(stream, {
2833
headers: {'Content-Type': 'text/html'},
2934
});

fixtures/ssr/server/render.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@ export default function render(url, res) {
2828
// If something errored before we started streaming, we set the error code appropriately.
2929
res.statusCode = didError ? 500 : 200;
3030
res.setHeader('Content-type', 'text/html');
31-
// There's no way to render a doctype in React so prepend manually.
32-
res.write('<!DOCTYPE html>');
3331
startWriting();
3432
},
3533
onError(x) {

packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,19 @@ describe('ReactDOMFizzServer', () => {
5858
expect(result).toMatchInlineSnapshot(`"<div>hello world</div>"`);
5959
});
6060

61+
// @gate experimental
62+
it('should emit DOCTYPE at the root of the document', async () => {
63+
const stream = ReactDOMFizzServer.renderToReadableStream(
64+
<html>
65+
<body>hello world</body>
66+
</html>,
67+
);
68+
const result = await readResult(stream);
69+
expect(result).toMatchInlineSnapshot(
70+
`"<!DOCTYPE html><html><body>hello world</body></html>"`,
71+
);
72+
});
73+
6174
// @gate experimental
6275
it('emits all HTML as one unit if we wait until the end to start', async () => {
6376
let hasLoaded = false;

packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,22 @@ describe('ReactDOMFizzServer', () => {
6868
expect(output.result).toMatchInlineSnapshot(`"<div>hello world</div>"`);
6969
});
7070

71+
// @gate experimental
72+
it('should emit DOCTYPE at the root of the document', () => {
73+
const {writable, output} = getTestWritable();
74+
const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
75+
<html>
76+
<body>hello world</body>
77+
</html>,
78+
writable,
79+
);
80+
startWriting();
81+
jest.runAllTimers();
82+
expect(output.result).toMatchInlineSnapshot(
83+
`"<!DOCTYPE html><html><body>hello world</body></html>"`,
84+
);
85+
});
86+
7187
// @gate experimental
7288
it('should start writing after startWriting', () => {
7389
const {writable, output} = getTestWritable();

packages/react-dom/src/__tests__/ReactRenderDocument-test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ describe('rendering React components at document', () => {
4949
}
5050

5151
const markup = ReactDOMServer.renderToString(<Root hello="world" />);
52+
expect(markup).not.toContain('DOCTYPE');
5253
const testDocument = getTestDocument(markup);
5354
const body = testDocument.body;
5455

packages/react-dom/src/server/ReactDOMLegacyServerBrowser.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ function renderToStringImpl(
6464
generateStaticMarkup,
6565
options ? options.identifierPrefix : undefined,
6666
),
67-
createRootFormatContext(undefined),
67+
createRootFormatContext(),
6868
Infinity,
6969
onError,
7070
undefined,

packages/react-dom/src/server/ReactDOMLegacyServerNode.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ function renderToNodeStreamImpl(
7979
children,
8080
destination,
8181
createResponseState(false, options ? options.identifierPrefix : undefined),
82-
createRootFormatContext(undefined),
82+
createRootFormatContext(),
8383
Infinity,
8484
onError,
8585
onCompleteAll,

packages/react-dom/src/server/ReactDOMServerFormatConfig.js

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,10 @@ export function createResponseState(
8787
// Constants for the insertion mode we're currently writing in. We don't encode all HTML5 insertion
8888
// modes. We only include the variants as they matter for the sake of our purposes.
8989
// We don't actually provide the namespace therefore we use constants instead of the string.
90-
const HTML_MODE = 0;
91-
const SVG_MODE = 1;
92-
const MATHML_MODE = 2;
90+
const ROOT_HTML_MODE = 0; // Used for the root most element tag.
91+
export const HTML_MODE = 1;
92+
const SVG_MODE = 2;
93+
const MATHML_MODE = 3;
9394
const HTML_TABLE_MODE = 4;
9495
const HTML_TABLE_BODY_MODE = 5;
9596
const HTML_TABLE_ROW_MODE = 6;
@@ -121,7 +122,7 @@ export function createRootFormatContext(namespaceURI?: string): FormatContext {
121122
? SVG_MODE
122123
: namespaceURI === 'http://www.w3.org/1998/Math/MathML'
123124
? MATHML_MODE
124-
: HTML_MODE;
125+
: ROOT_HTML_MODE;
125126
return createFormatContext(insertionMode, null);
126127
}
127128

@@ -160,6 +161,10 @@ export function getChildFormatContext(
160161
// entered plain HTML again.
161162
return createFormatContext(HTML_MODE, null);
162163
}
164+
if (parentContext.insertionMode === ROOT_HTML_MODE) {
165+
// We've emitted the root and is now in plain HTML mode.
166+
return createFormatContext(HTML_MODE, null);
167+
}
163168
return parentContext;
164169
}
165170

@@ -1262,6 +1267,8 @@ function startChunkForTag(tag: string): PrecomputedChunk {
12621267
return tagStartChunk;
12631268
}
12641269

1270+
const DOCTYPE: PrecomputedChunk = stringToPrecomputedChunk('<!DOCTYPE html>');
1271+
12651272
export function pushStartInstance(
12661273
target: Array<Chunk | PrecomputedChunk>,
12671274
type: string,
@@ -1371,6 +1378,21 @@ export function pushStartInstance(
13711378
assignID,
13721379
);
13731380
}
1381+
case 'html': {
1382+
if (formatContext.insertionMode === ROOT_HTML_MODE) {
1383+
// If we're rendering the html tag and we're at the root (i.e. not in foreignObject)
1384+
// then we also emit the DOCTYPE as part of the root content as a convenience for
1385+
// rendering the whole document.
1386+
target.push(DOCTYPE);
1387+
}
1388+
return pushStartGenericElement(
1389+
target,
1390+
props,
1391+
type,
1392+
responseState,
1393+
assignID,
1394+
);
1395+
}
13741396
default: {
13751397
if (type.indexOf('-') === -1 && typeof props.is !== 'string') {
13761398
// Generic element
@@ -1541,6 +1563,7 @@ export function writeStartSegment(
15411563
id: number,
15421564
): boolean {
15431565
switch (formatContext.insertionMode) {
1566+
case ROOT_HTML_MODE:
15441567
case HTML_MODE: {
15451568
writeChunk(destination, startSegmentHTML);
15461569
writeChunk(destination, responseState.segmentPrefix);
@@ -1597,6 +1620,7 @@ export function writeEndSegment(
15971620
formatContext: FormatContext,
15981621
): boolean {
15991622
switch (formatContext.insertionMode) {
1623+
case ROOT_HTML_MODE:
16001624
case HTML_MODE: {
16011625
return writeChunk(destination, endSegmentHTML);
16021626
}

packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
* @flow
88
*/
99

10-
import type {SuspenseBoundaryID} from './ReactDOMServerFormatConfig';
10+
import type {
11+
SuspenseBoundaryID,
12+
FormatContext,
13+
} from './ReactDOMServerFormatConfig';
1114

1215
import {
1316
createResponseState as createResponseStateImpl,
@@ -16,6 +19,7 @@ import {
1619
writeStartClientRenderedSuspenseBoundary as writeStartClientRenderedSuspenseBoundaryImpl,
1720
writeEndCompletedSuspenseBoundary as writeEndCompletedSuspenseBoundaryImpl,
1821
writeEndClientRenderedSuspenseBoundary as writeEndClientRenderedSuspenseBoundaryImpl,
22+
HTML_MODE,
1923
} from './ReactDOMServerFormatConfig';
2024

2125
import type {
@@ -62,14 +66,20 @@ export function createResponseState(
6266
};
6367
}
6468

69+
export function createRootFormatContext(): FormatContext {
70+
return {
71+
insertionMode: HTML_MODE, // We skip the root mode because we don't want to emit the DOCTYPE in legacy mode.
72+
selectedValue: null,
73+
};
74+
}
75+
6576
export type {
6677
FormatContext,
6778
SuspenseBoundaryID,
6879
OpaqueIDType,
6980
} from './ReactDOMServerFormatConfig';
7081

7182
export {
72-
createRootFormatContext,
7383
getChildFormatContext,
7484
createSuspenseBoundaryID,
7585
makeServerID,

0 commit comments

Comments
 (0)