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.
Sebastian Markbåge committed
Jun 14, 2021 at 16:57 UTC
bd45ad05dc4fe05f289d482bfd0111bee5d09ef4
9 files changed
+80
-13
fixtures/fizz-ssr-browser/index.html
+8
-3
@@ -21,9 +21,14 @@
21
<script src="https://unpkg.com/babel-standalone@6/babel.js"></script>
22
<script type="text/babel">
23
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
+ );
32
let response = new Response(stream, {
33
headers: {'Content-Type': 'text/html'},
34
});
fixtures/ssr/server/render.js
-2
@@ -28,8 +28,6 @@ export default function render(url, res) {
28
// If something errored before we started streaming, we set the error code appropriately.
29
res.statusCode = didError ? 500 : 200;
30
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>');
31
startWriting();
32
},
33
onError(x) {
packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js
+13
@@ -58,6 +58,19 @@ describe('ReactDOMFizzServer', () => {
58
expect(result).toMatchInlineSnapshot(`"<div>hello world</div>"`);
59
});
60
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
+
74
// @gate experimental
75
it('emits all HTML as one unit if we wait until the end to start', async () => {
76
let hasLoaded = false;
packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js
+16
@@ -68,6 +68,22 @@ describe('ReactDOMFizzServer', () => {
68
expect(output.result).toMatchInlineSnapshot(`"<div>hello world</div>"`);
69
});
70
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
+
87
// @gate experimental
88
it('should start writing after startWriting', () => {
89
const {writable, output} = getTestWritable();
packages/react-dom/src/__tests__/ReactRenderDocument-test.js
+1
@@ -49,6 +49,7 @@ describe('rendering React components at document', () => {
49
}
50
51
const markup = ReactDOMServer.renderToString(<Root hello="world" />);
52
+ expect(markup).not.toContain('DOCTYPE');
53
const testDocument = getTestDocument(markup);
54
const body = testDocument.body;
55
packages/react-dom/src/server/ReactDOMLegacyServerBrowser.js
+1
-1
@@ -64,7 +64,7 @@ function renderToStringImpl(
64
generateStaticMarkup,
65
options ? options.identifierPrefix : undefined,
66
),
67
- createRootFormatContext(undefined),
67
+ createRootFormatContext(),
68
Infinity,
69
onError,
70
undefined,
packages/react-dom/src/server/ReactDOMLegacyServerNode.js
+1
-1
@@ -79,7 +79,7 @@ function renderToNodeStreamImpl(
79
children,
80
destination,
81
createResponseState(false, options ? options.identifierPrefix : undefined),
82
- createRootFormatContext(undefined),
82
+ createRootFormatContext(),
83
Infinity,
84
onError,
85
onCompleteAll,
packages/react-dom/src/server/ReactDOMServerFormatConfig.js
+28
-4
@@ -87,9 +87,10 @@ export function createResponseState(
87
// Constants for the insertion mode we're currently writing in. We don't encode all HTML5 insertion
88
// modes. We only include the variants as they matter for the sake of our purposes.
89
// 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;
94
const HTML_TABLE_MODE = 4;
95
const HTML_TABLE_BODY_MODE = 5;
96
const HTML_TABLE_ROW_MODE = 6;
@@ -121,7 +122,7 @@ export function createRootFormatContext(namespaceURI?: string): FormatContext {
122
? SVG_MODE
123
: namespaceURI === 'http://www.w3.org/1998/Math/MathML'
124
? MATHML_MODE
124
- : HTML_MODE;
125
+ : ROOT_HTML_MODE;
126
return createFormatContext(insertionMode, null);
127
}
128
@@ -160,6 +161,10 @@ export function getChildFormatContext(
161
// entered plain HTML again.
162
return createFormatContext(HTML_MODE, null);
163
}
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
+ }
168
return parentContext;
169
}
170
@@ -1262,6 +1267,8 @@ function startChunkForTag(tag: string): PrecomputedChunk {
1267
return tagStartChunk;
1268
}
1269
1270
+const DOCTYPE: PrecomputedChunk = stringToPrecomputedChunk('<!DOCTYPE html>');
1271
+
1272
export function pushStartInstance(
1273
target: Array<Chunk | PrecomputedChunk>,
1274
type: string,
@@ -1371,6 +1378,21 @@ export function pushStartInstance(
1378
assignID,
1379
);
1380
}
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
+ }
1396
default: {
1397
if (type.indexOf('-') === -1 && typeof props.is !== 'string') {
1398
// Generic element
@@ -1541,6 +1563,7 @@ export function writeStartSegment(
1563
id: number,
1564
): boolean {
1565
switch (formatContext.insertionMode) {
1566
+ case ROOT_HTML_MODE:
1567
case HTML_MODE: {
1568
writeChunk(destination, startSegmentHTML);
1569
writeChunk(destination, responseState.segmentPrefix);
@@ -1597,6 +1620,7 @@ export function writeEndSegment(
1620
formatContext: FormatContext,
1621
): boolean {
1622
switch (formatContext.insertionMode) {
1623
+ case ROOT_HTML_MODE:
1624
case HTML_MODE: {
1625
return writeChunk(destination, endSegmentHTML);
1626
}
packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js
+12
-2
@@ -7,7 +7,10 @@
7
* @flow
8
*/
9
10
-import type {SuspenseBoundaryID} from './ReactDOMServerFormatConfig';
10
+import type {
11
+ SuspenseBoundaryID,
12
+ FormatContext,
13
+} from './ReactDOMServerFormatConfig';
14
15
import {
16
createResponseState as createResponseStateImpl,
@@ -16,6 +19,7 @@ import {
19
writeStartClientRenderedSuspenseBoundary as writeStartClientRenderedSuspenseBoundaryImpl,
20
writeEndCompletedSuspenseBoundary as writeEndCompletedSuspenseBoundaryImpl,
21
writeEndClientRenderedSuspenseBoundary as writeEndClientRenderedSuspenseBoundaryImpl,
22
+ HTML_MODE,
23
} from './ReactDOMServerFormatConfig';
24
25
import type {
@@ -62,6 +66,13 @@ export function createResponseState(
66
};
67
}
68
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
+
76
export type {
77
FormatContext,
78
SuspenseBoundaryID,
@@ -69,7 +80,6 @@ export type {
80
} from './ReactDOMServerFormatConfig';
81
82
export {
72
- createRootFormatContext,
83
getChildFormatContext,
84
createSuspenseBoundaryID,
85
makeServerID,