Skip to content

Commit b10b923

Browse files
committed
renderToString is a legacy server API which used a trick to avoid having the DOCTYPE included when rendering full documents by setting the root formatcontext to HTML_MODE rather than ROOT_HTML_MODE. Previously this was of little consequence but with Float the Root mode started to be used for things like determining if we could flush hoistable elements yet. In issue #27177 we see that hoisted elements can appear before the <html> tag when using a legacy API renderToString.
This change makes the FormatContext responsible for providing the DOCTYPE chunks rather than the insertionMode. Alongside this the legacy mode now uses the ROOT_HTML_MODE insertionMode so that it follows standard logic for hoisting. The reason I went with this approach is it allows us to avoid any runtime checks for whether we should or should not include a doctype. The only runtime perf consequence is that for legacy APIs when you render <html> there is an extra empty chunk to write which is of tivial consequence
1 parent b277259 commit b10b923

File tree

3 files changed

+64
-8
lines changed

3 files changed

+64
-8
lines changed

packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -371,11 +371,11 @@ export function createResponseState(
371371
// Constants for the insertion mode we're currently writing in. We don't encode all HTML5 insertion
372372
// modes. We only include the variants as they matter for the sake of our purposes.
373373
// We don't actually provide the namespace therefore we use constants instead of the string.
374-
const ROOT_HTML_MODE = 0; // Used for the root most element tag.
374+
export const ROOT_HTML_MODE = 0; // Used for the root most element tag.
375375
// We have a less than HTML_HTML_MODE check elsewhere. If you add more cases here, make sure it
376376
// still makes sense
377377
const HTML_HTML_MODE = 1; // Used for the <html> if it is at the top level.
378-
export const HTML_MODE = 2;
378+
const HTML_MODE = 2;
379379
const SVG_MODE = 3;
380380
const MATHML_MODE = 4;
381381
const HTML_TABLE_MODE = 5;
@@ -392,6 +392,7 @@ export type FormatContext = {
392392
insertionMode: InsertionMode, // root/svg/html/mathml/table
393393
selectedValue: null | string | Array<string>, // the selected value(s) inside a <select>, or null outside <select>
394394
noscriptTagInScope: boolean,
395+
DOCTYPE: PrecomputedChunk,
395396
};
396397

397398
function createFormatContext(
@@ -403,6 +404,11 @@ function createFormatContext(
403404
insertionMode,
404405
selectedValue,
405406
noscriptTagInScope,
407+
// In practice we only need this for the root format context where it conditions the modern/legacy behavior.
408+
// However to avoid type variance we include it on all contexts. If we ever read this in legacy mode for any
409+
// context other than the root context we would need forward this from parent contexts so the empty doctype
410+
// in legacy mode carries forward. In the current setup this isn't necessary
411+
DOCTYPE,
406412
};
407413
}
408414

@@ -2676,11 +2682,12 @@ function pushStartHtml(
26762682
props: Object,
26772683
responseState: ResponseState,
26782684
insertionMode: InsertionMode,
2685+
doctypeChunk: PrecomputedChunk,
26792686
): ReactNodeList {
26802687
if (enableFloat) {
26812688
if (insertionMode === ROOT_HTML_MODE && responseState.htmlChunks === null) {
26822689
// This <html> is the Document.documentElement and should be part of the preamble
2683-
responseState.htmlChunks = [DOCTYPE];
2690+
responseState.htmlChunks = [doctypeChunk];
26842691
return pushStartGenericElement(responseState.htmlChunks, props, 'html');
26852692
} else {
26862693
// This <html> is deep and is likely just an error. we emit it inline though.
@@ -2692,7 +2699,7 @@ function pushStartHtml(
26922699
// If we're rendering the html tag and we're at the root (i.e. not in foreignObject)
26932700
// then we also emit the DOCTYPE as part of the root content as a convenience for
26942701
// rendering the whole document.
2695-
target.push(DOCTYPE);
2702+
target.push(doctypeChunk);
26962703
}
26972704
return pushStartGenericElement(target, props, 'html');
26982705
}
@@ -3196,6 +3203,7 @@ export function pushStartInstance(
31963203
props,
31973204
responseState,
31983205
formatContext.insertionMode,
3206+
formatContext.DOCTYPE,
31993207
);
32003208
}
32013209
default: {

packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
writeStartClientRenderedSuspenseBoundary as writeStartClientRenderedSuspenseBoundaryImpl,
2525
writeEndCompletedSuspenseBoundary as writeEndCompletedSuspenseBoundaryImpl,
2626
writeEndClientRenderedSuspenseBoundary as writeEndClientRenderedSuspenseBoundaryImpl,
27-
HTML_MODE,
27+
ROOT_HTML_MODE,
2828
} from './ReactFizzConfigDOM';
2929

3030
import type {
@@ -104,11 +104,20 @@ export function createResponseState(
104104
};
105105
}
106106

107+
import {
108+
stringToChunk,
109+
stringToPrecomputedChunk,
110+
} from 'react-server/src/ReactServerStreamConfig';
111+
112+
// this chunk is empty on purpose because we do not want to emit the DOCTYPE in legacy mode
113+
const DOCTYPE = stringToPrecomputedChunk('');
114+
107115
export function createRootFormatContext(): FormatContext {
108116
return {
109-
insertionMode: HTML_MODE, // We skip the root mode because we don't want to emit the DOCTYPE in legacy mode.
117+
insertionMode: ROOT_HTML_MODE,
110118
selectedValue: null,
111119
noscriptTagInScope: false,
120+
DOCTYPE,
112121
};
113122
}
114123

@@ -148,8 +157,6 @@ export {
148157
prepareHostDispatcher,
149158
} from './ReactFizzConfigDOM';
150159

151-
import {stringToChunk} from 'react-server/src/ReactServerStreamConfig';
152-
153160
import escapeTextForBrowser from './escapeTextForBrowser';
154161

155162
export function pushTextInstance(
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @emails react-core
8+
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
9+
*/
10+
11+
'use strict';
12+
13+
let React;
14+
let ReactDOMFizzServer;
15+
16+
describe('ReactDOMFloat', () => {
17+
beforeEach(() => {
18+
jest.resetModules();
19+
20+
React = require('react');
21+
ReactDOMFizzServer = require('react-dom/server');
22+
});
23+
24+
// fixes #27177
25+
// @gate enableFloat
26+
it('does not hoist above the <html> tag', async () => {
27+
const result = ReactDOMFizzServer.renderToString(
28+
<html>
29+
<head>
30+
<script src="foo" />
31+
<meta charSet="utf-8" />
32+
<title>title</title>
33+
</head>
34+
</html>,
35+
);
36+
37+
expect(result).toEqual(
38+
'<html><head><meta charSet="utf-8"/><title>title</title><script src="foo"></script></head></html>',
39+
);
40+
});
41+
});

0 commit comments

Comments
 (0)