diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationAttributes-test.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationAttributes-test.js index 2838ad660afd..9dd17988a2e5 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationAttributes-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationAttributes-test.js @@ -32,6 +32,7 @@ function initModules() { const { resetModules, itRenders, + itRendersNonStandard, clientCleanRender, } = ReactDOMServerIntegrationUtils(initModules); @@ -604,6 +605,16 @@ describe('ReactDOMServerIntegration', () => { expect(e.getAttribute('foo')).toBe('bar'); }); + itRendersNonStandard( + 'non-standard attributes for non-standard elements', + async render => { + const e = await render( + , + ); + expect(e.getAttribute('[non-standard]')).toBe('test'); + }, + ); + itRenders('SVG tags with dashes in them', async render => { const e = await render( diff --git a/packages/react-dom/src/__tests__/ReactServerRenderingBrowser-test.js b/packages/react-dom/src/__tests__/ReactServerRenderingBrowser-test.js index c3a3ff11d783..5b3b9fe75f95 100644 --- a/packages/react-dom/src/__tests__/ReactServerRenderingBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactServerRenderingBrowser-test.js @@ -52,6 +52,40 @@ describe('ReactServerRenderingBrowser', () => { ); }); + it('returns the same non-standard results as react-dom/server', () => { + class NiceNonStandard extends React.Component { + render() { + return ( + + I am feeling very good today, thanks, how are you? + + ); + } + } + function GreetingNonStandard() { + return ( +
+ + How are you? + + +
+ ); + } + expect( + ReactDOMServerBrowser.renderToStringNonStandard(), + ).toEqual( + ReactDOMServer.renderToStringNonStandard(), + ); + expect( + ReactDOMServerBrowser.renderToStaticMarkupNonStandard( + , + ), + ).toEqual( + ReactDOMServer.renderToStaticMarkupNonStandard(), + ); + }); + it('throws meaningfully for server-only APIs', () => { expect(() => ReactDOMServerBrowser.renderToNodeStream(
)).toThrow( 'ReactDOMServer.renderToNodeStream(): The streaming API is not available ' + @@ -63,5 +97,17 @@ describe('ReactServerRenderingBrowser', () => { 'ReactDOMServer.renderToStaticNodeStream(): The streaming API is not available ' + 'in the browser. Use ReactDOMServer.renderToStaticMarkup() instead.', ); + expect(() => + ReactDOMServerBrowser.renderToNodeStreamNonStandard(
), + ).toThrow( + 'ReactDOMServer.renderToNodeStreamNonStandard(): The streaming API is not available ' + + 'in the browser. Use ReactDOMServer.renderToStringNonStandard() instead.', + ); + expect(() => + ReactDOMServerBrowser.renderToStaticNodeStreamNonStandard(
), + ).toThrow( + 'ReactDOMServer.renderToStaticNodeStreamNonStandard(): The streaming API is not available ' + + 'in the browser. Use ReactDOMServer.renderToStaticMarkupNonStandard() instead.', + ); }); }); diff --git a/packages/react-dom/src/__tests__/utils/ReactDOMServerIntegrationTestUtils.js b/packages/react-dom/src/__tests__/utils/ReactDOMServerIntegrationTestUtils.js index f24c2917dfd8..75de66dd40ff 100644 --- a/packages/react-dom/src/__tests__/utils/ReactDOMServerIntegrationTestUtils.js +++ b/packages/react-dom/src/__tests__/utils/ReactDOMServerIntegrationTestUtils.js @@ -84,12 +84,23 @@ module.exports = function(initModules) { }, errorCount); } - async function renderIntoString(reactElement, errorCount = 0) { + async function renderIntoString( + reactElement, + errorCount = 0, + allowNonStandard, + ) { return await expectErrors( () => - new Promise(resolve => - resolve(ReactDOMServer.renderToString(reactElement)), - ), + new Promise(resolve => { + let result; + if (allowNonStandard) { + result = ReactDOMServer.renderToStringNonStandard(reactElement); + } else { + result = ReactDOMServer.renderToString(reactElement); + } + + resolve(result); + }), errorCount, ); } @@ -98,7 +109,17 @@ module.exports = function(initModules) { // element that corresponds with the reactElement. // Does not render on client or perform client-side revival. async function serverRender(reactElement, errorCount = 0) { - const markup = await renderIntoString(reactElement, errorCount); + const markup = await renderIntoString(reactElement, errorCount, false); + const domElement = document.createElement('div'); + domElement.innerHTML = markup; + return domElement.firstChild; + } + + // Renders text using SSR and then stuffs it into a DOM node; returns the DOM + // element that corresponds with the reactElement. + // Does not render on client or perform client-side revival. + async function serverRenderNonStandard(reactElement, errorCount = 0) { + const markup = await renderIntoString(reactElement, errorCount, true); const domElement = document.createElement('div'); domElement.innerHTML = markup; return domElement.firstChild; @@ -118,12 +139,26 @@ module.exports = function(initModules) { } } - async function renderIntoStream(reactElement, errorCount = 0) { + async function renderIntoStream( + reactElement, + errorCount = 0, + allowNonStandard, + ) { return await expectErrors( () => new Promise(resolve => { let writable = new DrainWritable(); - ReactDOMServer.renderToNodeStream(reactElement).pipe(writable); + + let nodeStream; + if (allowNonStandard) { + nodeStream = ReactDOMServer.renderToNodeStreamNonStandard( + reactElement, + ); + } else { + nodeStream = ReactDOMServer.renderToNodeStream(reactElement); + } + + nodeStream.pipe(writable); writable.on('finish', () => resolve(writable.buffer)); }), errorCount, @@ -134,7 +169,17 @@ module.exports = function(initModules) { // returns the DOM element that corresponds with the reactElement. // Does not render on client or perform client-side revival. async function streamRender(reactElement, errorCount = 0) { - const markup = await renderIntoStream(reactElement, errorCount); + const markup = await renderIntoStream(reactElement, errorCount, false); + const domElement = document.createElement('div'); + domElement.innerHTML = markup; + return domElement.firstChild; + } + + // Renders non-standard text using node stream SSR and then stuffs it into a + // DOM node; returns the DOM element that corresponds with the reactElement. + // Does not render on client or perform client-side revival. + async function streamRenderNonStandard(reactElement, errorCount = 0) { + const markup = await renderIntoStream(reactElement, errorCount, true); const domElement = document.createElement('div'); domElement.innerHTML = markup; return domElement.firstChild; @@ -221,6 +266,23 @@ module.exports = function(initModules) { itClientRenders(desc, testFn); } + // Runs a DOM rendering test for rendering to a non-standard string on server. + // Non-standard strings cannot render on client without errors. + // + // testFn is a test that has one arg, which is a render function. the render + // function takes in a ReactElement and an optional expected error count and + // returns a promise of a DOM Element. + // + // You should only perform tests that examine the DOM of the results of + // render; you should not depend on the interactivity of the returned DOM element, + // as that will not work in the server string scenario. + function itRendersNonStandard(desc, testFn) { + it(`renders ${desc} with server non-standard string render`, () => + testFn(serverRenderNonStandard)); + it(`renders ${desc} with server non-standard stream render`, () => + testFn(streamRenderNonStandard)); + } + // run testFn in three different rendering scenarios: // -- render on client without any server markup "clean client render" // -- render on client on top of good server-generated string markup @@ -319,6 +381,7 @@ module.exports = function(initModules) { expectMarkupMismatch, expectMarkupMatch, itRenders, + itRendersNonStandard, itClientRenders, itThrowsWhenRendering, asyncReactDOMRender, diff --git a/packages/react-dom/src/server/DOMMarkupOperations.js b/packages/react-dom/src/server/DOMMarkupOperations.js index 37eb5de9d4dd..ab47c7d5f639 100644 --- a/packages/react-dom/src/server/DOMMarkupOperations.js +++ b/packages/react-dom/src/server/DOMMarkupOperations.js @@ -75,8 +75,9 @@ export function createMarkupForProperty(name: string, value: mixed): string { export function createMarkupForCustomAttribute( name: string, value: mixed, + allowNonStandard: boolean, ): string { - if (!isAttributeNameSafe(name) || value == null) { + if ((!allowNonStandard && !isAttributeNameSafe(name)) || value == null) { return ''; } return name + '=' + quoteAttributeValueForBrowser(value); diff --git a/packages/react-dom/src/server/ReactDOMNodeStreamRenderer.js b/packages/react-dom/src/server/ReactDOMNodeStreamRenderer.js index a77cce55cf15..eb54d6b6dd01 100644 --- a/packages/react-dom/src/server/ReactDOMNodeStreamRenderer.js +++ b/packages/react-dom/src/server/ReactDOMNodeStreamRenderer.js @@ -11,11 +11,15 @@ import ReactPartialRenderer from './ReactPartialRenderer'; // This is a Readable Node.js stream which wraps the ReactDOMPartialRenderer. class ReactMarkupReadableStream extends Readable { - constructor(element, makeStaticMarkup) { + constructor(element, makeStaticMarkup, allowNonStandard) { // Calls the stream.Readable(options) constructor. Consider exposing built-in // features like highWaterMark in the future. super({}); - this.partialRenderer = new ReactPartialRenderer(element, makeStaticMarkup); + this.partialRenderer = new ReactPartialRenderer( + element, + makeStaticMarkup, + allowNonStandard, + ); } _read(size) { @@ -32,7 +36,7 @@ class ReactMarkupReadableStream extends Readable { * See https://reactjs.org/docs/react-dom-stream.html#rendertonodestream */ export function renderToNodeStream(element) { - return new ReactMarkupReadableStream(element, false); + return new ReactMarkupReadableStream(element, false, false); } /** @@ -41,5 +45,23 @@ export function renderToNodeStream(element) { * See https://reactjs.org/docs/react-dom-stream.html#rendertostaticnodestream */ export function renderToStaticNodeStream(element) { - return new ReactMarkupReadableStream(element, true); + return new ReactMarkupReadableStream(element, true, false); +} + +/** + * Render a ReactElement to its initial non-standard HTML. This should only be + * used on the server. + * See https://reactjs.org/docs/react-dom-stream.html#rendertonodestream + */ +export function renderToNodeStreamNonStandard(element) { + return new ReactMarkupReadableStream(element, false, true); +} + +/** + * Similar to renderToNodeStreamNonStandard, except this doesn't create extra + * DOM attributes such as data-react-id that React uses internally. + * See https://reactjs.org/docs/react-dom-stream.html#rendertostaticnodestream + */ +export function renderToStaticNodeStreamNonStandard(element) { + return new ReactMarkupReadableStream(element, true, true); } diff --git a/packages/react-dom/src/server/ReactDOMServerBrowser.js b/packages/react-dom/src/server/ReactDOMServerBrowser.js index 1edfbce979ac..6d5fc4cc463e 100644 --- a/packages/react-dom/src/server/ReactDOMServerBrowser.js +++ b/packages/react-dom/src/server/ReactDOMServerBrowser.js @@ -8,7 +8,12 @@ import ReactVersion from 'shared/ReactVersion'; import invariant from 'fbjs/lib/invariant'; -import {renderToString, renderToStaticMarkup} from './ReactDOMStringRenderer'; +import { + renderToString, + renderToStaticMarkup, + renderToStringNonStandard, + renderToStaticMarkupNonStandard, +} from './ReactDOMStringRenderer'; function renderToNodeStream() { invariant( @@ -26,11 +31,31 @@ function renderToStaticNodeStream() { ); } +function renderToNodeStreamNonStandard() { + invariant( + false, + 'ReactDOMServer.renderToNodeStreamNonStandard(): The streaming API is not available ' + + 'in the browser. Use ReactDOMServer.renderToStringNonStandard() instead.', + ); +} + +function renderToStaticNodeStreamNonStandard() { + invariant( + false, + 'ReactDOMServer.renderToStaticNodeStreamNonStandard(): The streaming API is not available ' + + 'in the browser. Use ReactDOMServer.renderToStaticMarkupNonStandard() instead.', + ); +} + // Note: when changing this, also consider https://github.com/facebook/react/issues/11526 export default { renderToString, renderToStaticMarkup, + renderToStringNonStandard, + renderToStaticMarkupNonStandard, renderToNodeStream, renderToStaticNodeStream, + renderToNodeStreamNonStandard, + renderToStaticNodeStreamNonStandard, version: ReactVersion, }; diff --git a/packages/react-dom/src/server/ReactDOMServerNode.js b/packages/react-dom/src/server/ReactDOMServerNode.js index d8de36490552..631bb6c511d5 100644 --- a/packages/react-dom/src/server/ReactDOMServerNode.js +++ b/packages/react-dom/src/server/ReactDOMServerNode.js @@ -7,17 +7,28 @@ import ReactVersion from 'shared/ReactVersion'; -import {renderToString, renderToStaticMarkup} from './ReactDOMStringRenderer'; +import { + renderToString, + renderToStaticMarkup, + renderToStringNonStandard, + renderToStaticMarkupNonStandard, +} from './ReactDOMStringRenderer'; import { renderToNodeStream, renderToStaticNodeStream, + renderToNodeStreamNonStandard, + renderToStaticNodeStreamNonStandard, } from './ReactDOMNodeStreamRenderer'; // Note: when changing this, also consider https://github.com/facebook/react/issues/11526 export default { renderToString, renderToStaticMarkup, + renderToStringNonStandard, + renderToStaticMarkupNonStandard, renderToNodeStream, renderToStaticNodeStream, + renderToNodeStreamNonStandard, + renderToStaticNodeStreamNonStandard, version: ReactVersion, }; diff --git a/packages/react-dom/src/server/ReactDOMStringRenderer.js b/packages/react-dom/src/server/ReactDOMStringRenderer.js index 829256e2a666..dfd6e302fec2 100644 --- a/packages/react-dom/src/server/ReactDOMStringRenderer.js +++ b/packages/react-dom/src/server/ReactDOMStringRenderer.js @@ -13,7 +13,7 @@ import ReactPartialRenderer from './ReactPartialRenderer'; * See https://reactjs.org/docs/react-dom-server.html#rendertostring */ export function renderToString(element) { - const renderer = new ReactPartialRenderer(element, false); + const renderer = new ReactPartialRenderer(element, false, false); const markup = renderer.read(Infinity); return markup; } @@ -24,7 +24,29 @@ export function renderToString(element) { * See https://reactjs.org/docs/react-dom-server.html#rendertostaticmarkup */ export function renderToStaticMarkup(element) { - const renderer = new ReactPartialRenderer(element, true); + const renderer = new ReactPartialRenderer(element, true, false); + const markup = renderer.read(Infinity); + return markup; +} + +/** + * Render a ReactElement to its initial non-standard HTML. This should only be + * used on the server. + * See https://reactjs.org/docs/react-dom-server.html#rendertostring + */ +export function renderToStringNonStandard(element) { + const renderer = new ReactPartialRenderer(element, false, true); + const markup = renderer.read(Infinity); + return markup; +} + +/** + * Similar to renderToStringNonStandard, except this doesn't create extra DOM + * attributes such as data-react-id that React uses internally. + * See https://reactjs.org/docs/react-dom-server.html#rendertostaticmarkup + */ +export function renderToStaticMarkupNonStandard(element) { + const renderer = new ReactPartialRenderer(element, true, true); const markup = renderer.read(Infinity); return markup; } diff --git a/packages/react-dom/src/server/ReactPartialRenderer.js b/packages/react-dom/src/server/ReactPartialRenderer.js index ec26eba2c1d2..8176da612322 100644 --- a/packages/react-dom/src/server/ReactPartialRenderer.js +++ b/packages/react-dom/src/server/ReactPartialRenderer.js @@ -324,6 +324,7 @@ function createOpenTagMarkup( namespace: string, makeStaticMarkup: boolean, isRootElement: boolean, + allowNonStandard: boolean, ): string { let ret = '<' + tagVerbatim; @@ -341,7 +342,11 @@ function createOpenTagMarkup( let markup = null; if (isCustomComponent(tagLowercase, props)) { if (!RESERVED_PROPS.hasOwnProperty(propKey)) { - markup = createMarkupForCustomAttribute(propKey, propValue); + markup = createMarkupForCustomAttribute( + propKey, + propValue, + allowNonStandard, + ); } } else { markup = createMarkupForProperty(propKey, propValue); @@ -641,11 +646,16 @@ class ReactDOMServerRenderer { currentSelectValue: any; previousWasTextNode: boolean; makeStaticMarkup: boolean; + allowNonStandard: boolean; providerStack: Array>; providerIndex: number; - constructor(children: mixed, makeStaticMarkup: boolean) { + constructor( + children: mixed, + makeStaticMarkup: boolean, + allowNonStandard: boolean, + ) { const flatChildren = flattenTopLevelChildren(children); const topFrame: Frame = { @@ -666,6 +676,7 @@ class ReactDOMServerRenderer { this.currentSelectValue = null; this.previousWasTextNode = false; this.makeStaticMarkup = makeStaticMarkup; + this.allowNonStandard = allowNonStandard; // Context (new API) this.providerStack = []; // Stack of provider objects @@ -1190,6 +1201,7 @@ class ReactDOMServerRenderer { namespace, this.makeStaticMarkup, this.stack.length === 1, + this.allowNonStandard, ); let footer = ''; if (omittedCloseTags.hasOwnProperty(tag)) {