Skip to content

Commit

Permalink
Add a DOCTYPE to the stream if the <html> tag is rendered (#21680)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
sebmarkbage committed Jun 14, 2021
1 parent a8f5e77 commit bd45ad0
Show file tree
Hide file tree
Showing 9 changed files with 80 additions and 13 deletions.
11 changes: 8 additions & 3 deletions fixtures/fizz-ssr-browser/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,14 @@ <h1>Fizz Example</h1>
<script src="https://unpkg.com/babel-standalone@6/babel.js"></script>
<script type="text/babel">
let controller = new AbortController();
let stream = ReactDOMFizzServer.renderToReadableStream(<body>Success</body>, {
signal: controller.signal,
});
let stream = ReactDOMFizzServer.renderToReadableStream(
<html>
<body>Success</body>
</html>,
{
signal: controller.signal,
}
);
let response = new Response(stream, {
headers: {'Content-Type': 'text/html'},
});
Expand Down
2 changes: 0 additions & 2 deletions fixtures/ssr/server/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ export default function render(url, res) {
// If something errored before we started streaming, we set the error code appropriately.
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-type', 'text/html');
// There's no way to render a doctype in React so prepend manually.
res.write('<!DOCTYPE html>');
startWriting();
},
onError(x) {
Expand Down
13 changes: 13 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,19 @@ describe('ReactDOMFizzServer', () => {
expect(result).toMatchInlineSnapshot(`"<div>hello world</div>"`);
});

// @gate experimental
it('should emit DOCTYPE at the root of the document', async () => {
const stream = ReactDOMFizzServer.renderToReadableStream(
<html>
<body>hello world</body>
</html>,
);
const result = await readResult(stream);
expect(result).toMatchInlineSnapshot(
`"<!DOCTYPE html><html><body>hello world</body></html>"`,
);
});

// @gate experimental
it('emits all HTML as one unit if we wait until the end to start', async () => {
let hasLoaded = false;
Expand Down
16 changes: 16 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,22 @@ describe('ReactDOMFizzServer', () => {
expect(output.result).toMatchInlineSnapshot(`"<div>hello world</div>"`);
});

// @gate experimental
it('should emit DOCTYPE at the root of the document', () => {
const {writable, output} = getTestWritable();
const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
<html>
<body>hello world</body>
</html>,
writable,
);
startWriting();
jest.runAllTimers();
expect(output.result).toMatchInlineSnapshot(
`"<!DOCTYPE html><html><body>hello world</body></html>"`,
);
});

// @gate experimental
it('should start writing after startWriting', () => {
const {writable, output} = getTestWritable();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ describe('rendering React components at document', () => {
}

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ function renderToStringImpl(
generateStaticMarkup,
options ? options.identifierPrefix : undefined,
),
createRootFormatContext(undefined),
createRootFormatContext(),
Infinity,
onError,
undefined,
Expand Down
2 changes: 1 addition & 1 deletion packages/react-dom/src/server/ReactDOMLegacyServerNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ function renderToNodeStreamImpl(
children,
destination,
createResponseState(false, options ? options.identifierPrefix : undefined),
createRootFormatContext(undefined),
createRootFormatContext(),
Infinity,
onError,
onCompleteAll,
Expand Down
32 changes: 28 additions & 4 deletions packages/react-dom/src/server/ReactDOMServerFormatConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,10 @@ export function createResponseState(
// Constants for the insertion mode we're currently writing in. We don't encode all HTML5 insertion
// modes. We only include the variants as they matter for the sake of our purposes.
// We don't actually provide the namespace therefore we use constants instead of the string.
const HTML_MODE = 0;
const SVG_MODE = 1;
const MATHML_MODE = 2;
const ROOT_HTML_MODE = 0; // Used for the root most element tag.
export const HTML_MODE = 1;
const SVG_MODE = 2;
const MATHML_MODE = 3;
const HTML_TABLE_MODE = 4;
const HTML_TABLE_BODY_MODE = 5;
const HTML_TABLE_ROW_MODE = 6;
Expand Down Expand Up @@ -121,7 +122,7 @@ export function createRootFormatContext(namespaceURI?: string): FormatContext {
? SVG_MODE
: namespaceURI === 'http://www.w3.org/1998/Math/MathML'
? MATHML_MODE
: HTML_MODE;
: ROOT_HTML_MODE;
return createFormatContext(insertionMode, null);
}

Expand Down Expand Up @@ -160,6 +161,10 @@ export function getChildFormatContext(
// entered plain HTML again.
return createFormatContext(HTML_MODE, null);
}
if (parentContext.insertionMode === ROOT_HTML_MODE) {
// We've emitted the root and is now in plain HTML mode.
return createFormatContext(HTML_MODE, null);
}
return parentContext;
}

Expand Down Expand Up @@ -1262,6 +1267,8 @@ function startChunkForTag(tag: string): PrecomputedChunk {
return tagStartChunk;
}

const DOCTYPE: PrecomputedChunk = stringToPrecomputedChunk('<!DOCTYPE html>');

export function pushStartInstance(
target: Array<Chunk | PrecomputedChunk>,
type: string,
Expand Down Expand Up @@ -1371,6 +1378,21 @@ export function pushStartInstance(
assignID,
);
}
case 'html': {
if (formatContext.insertionMode === ROOT_HTML_MODE) {
// If we're rendering the html tag and we're at the root (i.e. not in foreignObject)
// then we also emit the DOCTYPE as part of the root content as a convenience for
// rendering the whole document.
target.push(DOCTYPE);
}
return pushStartGenericElement(
target,
props,
type,
responseState,
assignID,
);
}
default: {
if (type.indexOf('-') === -1 && typeof props.is !== 'string') {
// Generic element
Expand Down Expand Up @@ -1541,6 +1563,7 @@ export function writeStartSegment(
id: number,
): boolean {
switch (formatContext.insertionMode) {
case ROOT_HTML_MODE:
case HTML_MODE: {
writeChunk(destination, startSegmentHTML);
writeChunk(destination, responseState.segmentPrefix);
Expand Down Expand Up @@ -1597,6 +1620,7 @@ export function writeEndSegment(
formatContext: FormatContext,
): boolean {
switch (formatContext.insertionMode) {
case ROOT_HTML_MODE:
case HTML_MODE: {
return writeChunk(destination, endSegmentHTML);
}
Expand Down
14 changes: 12 additions & 2 deletions packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
* @flow
*/

import type {SuspenseBoundaryID} from './ReactDOMServerFormatConfig';
import type {
SuspenseBoundaryID,
FormatContext,
} from './ReactDOMServerFormatConfig';

import {
createResponseState as createResponseStateImpl,
Expand All @@ -16,6 +19,7 @@ import {
writeStartClientRenderedSuspenseBoundary as writeStartClientRenderedSuspenseBoundaryImpl,
writeEndCompletedSuspenseBoundary as writeEndCompletedSuspenseBoundaryImpl,
writeEndClientRenderedSuspenseBoundary as writeEndClientRenderedSuspenseBoundaryImpl,
HTML_MODE,
} from './ReactDOMServerFormatConfig';

import type {
Expand Down Expand Up @@ -62,14 +66,20 @@ export function createResponseState(
};
}

export function createRootFormatContext(): FormatContext {
return {
insertionMode: HTML_MODE, // We skip the root mode because we don't want to emit the DOCTYPE in legacy mode.
selectedValue: null,
};
}

export type {
FormatContext,
SuspenseBoundaryID,
OpaqueIDType,
} from './ReactDOMServerFormatConfig';

export {
createRootFormatContext,
getChildFormatContext,
createSuspenseBoundaryID,
makeServerID,
Expand Down

0 comments on commit bd45ad0

Please sign in to comment.