Repository: facebook/react
Pull request: [Fizz] Support nonce option to be passed to inline scripts

Fizz currently generates inline scripts. If you have a Content Security Policy that blocks it, you need to provide a `nonce` attribute to it to allow those scripts. This just adds an option to pass through such an attribute.

However, a better strategy is probably that we expose an external script that can be loaded onto the page early through a `src` with a mutation observer that detects segments that we've inserted and runs the appropriate commands. That way we don't need to use inline scripts for this. It's also closer to what we want the ideal built-in feature for this in the DOM would look like so it'd be more of a polyfill.

However, it might still be better to use inline scripts for the cases where it is allowed and you haven't already loaded the script into cache. Also, if you already have a nonce anyway it might be better.

Changed files:
filename: packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
patch: @@ -23,6 +23,7 @@ let PropTypes;
 let textCache;
 let document;
 let writable;
+let CSPnonce = null;
 let container;
 let buffer = '';
 let hasErrored = false;
@@ -91,7 +92,10 @@ describe('ReactDOMFizzServer', () => {
     fakeBody.innerHTML = bufferedContent;
     while (fakeBody.firstChild) {
       const node = fakeBody.firstChild;
-      if (node.nodeName === 'SCRIPT') {
+      if (
+        node.nodeName === 'SCRIPT' &&
+        (CSPnonce === null || node.getAttribute('nonce') === CSPnonce)
+      ) {
         const script = document.createElement('script');
         script.textContent = node.textContent;
         fakeBody.removeChild(node);
@@ -281,6 +285,38 @@ describe('ReactDOMFizzServer', () => {
     );
   });
 
+  // @gate experimental
+  it('should support nonce scripts', async () => {
+    CSPnonce = 'R4nd0m';
+    try {
+      let resolve;
+      const Lazy = React.lazy(() => {
+        return new Promise(r => {
+          resolve = r;
+        });
+      });
+
+      await act(async () => {
+        const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
+          <div>
+            <Suspense fallback={<Text text="Loading..." />}>
+              <Lazy text="Hello" />
+            </Suspense>
+          </div>,
+          {nonce: 'R4nd0m'},
+        );
+        pipe(writable);
+      });
+      expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
+      await act(async () => {
+        resolve({default: Text});
+      });
+      expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
+    } finally {
+      CSPnonce = null;
+    }
+  });
+
   // @gate experimental
   it('should client render a boundary if a lazy component rejects', async () => {
     let rejectComponent;

filename: packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
patch: @@ -26,6 +26,7 @@ import {
 type Options = {|
   identifierPrefix?: string,
   namespaceURI?: string,
+  nonce?: string,
   progressiveChunkSize?: number,
   signal?: AbortSignal,
   onCompleteShell?: () => void,
@@ -39,7 +40,10 @@ function renderToReadableStream(
 ): ReadableStream {
   const request = createRequest(
     children,
-    createResponseState(options ? options.identifierPrefix : undefined),
+    createResponseState(
+      options ? options.identifierPrefix : undefined,
+      options ? options.nonce : undefined,
+    ),
     createRootFormatContext(options ? options.namespaceURI : undefined),
     options ? options.progressiveChunkSize : undefined,
     options ? options.onError : undefined,

filename: packages/react-dom/src/server/ReactDOMFizzServerNode.js
patch: @@ -31,6 +31,7 @@ function createDrainHandler(destination, request) {
 type Options = {|
   identifierPrefix?: string,
   namespaceURI?: string,
+  nonce?: string,
   progressiveChunkSize?: number,
   onCompleteShell?: () => void,
   onCompleteAll?: () => void,
@@ -47,7 +48,10 @@ type Controls = {|
 function createRequestImpl(children: ReactNodeList, options: void | Options) {
   return createRequest(
     children,
-    createResponseState(options ? options.identifierPrefix : undefined),
+    createResponseState(
+      options ? options.identifierPrefix : undefined,
+      options ? options.nonce : undefined,
+    ),
     createRootFormatContext(options ? options.namespaceURI : undefined),
     options ? options.progressiveChunkSize : undefined,
     options ? options.onError : undefined,

filename: packages/react-dom/src/server/ReactDOMServerFormatConfig.js
patch: @@ -59,6 +59,7 @@ export const isPrimaryRenderer = true;
 
 // Per response, global state that is not contextual to the rendering subtree.
 export type ResponseState = {
+  startInlineScript: PrecomputedChunk,
   placeholderPrefix: PrecomputedChunk,
   segmentPrefix: PrecomputedChunk,
   boundaryPrefix: string,
@@ -71,12 +72,22 @@ export type ResponseState = {
   ...
 };
 
+const startInlineScript = stringToPrecomputedChunk('<script>');
+
 // Allows us to keep track of what we've already written so we can refer back to it.
 export function createResponseState(
   identifierPrefix: string | void,
+  nonce: string | void,
 ): ResponseState {
   const idPrefix = identifierPrefix === undefined ? '' : identifierPrefix;
+  const inlineScriptWithNonce =
+    nonce === undefined
+      ? startInlineScript
+      : stringToPrecomputedChunk(
+          '<script nonce="' + escapeTextForBrowser(nonce) + '">',
+        );
   return {
+    startInlineScript: inlineScriptWithNonce,
     placeholderPrefix: stringToPrecomputedChunk(idPrefix + 'P:'),
     segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'),
     boundaryPrefix: idPrefix + 'B:',
@@ -1689,9 +1700,9 @@ const clientRenderFunction =
   'function $RX(a){if(a=document.getElementById(a))a=a.previousSibling,a.data="$!",a._reactRetry&&a._reactRetry()}';
 
 const completeSegmentScript1Full = stringToPrecomputedChunk(
-  '<script>' + completeSegmentFunction + ';$RS("',
+  completeSegmentFunction + ';$RS("',
 );
-const completeSegmentScript1Partial = stringToPrecomputedChunk('<script>$RS("');
+const completeSegmentScript1Partial = stringToPrecomputedChunk('$RS("');
 const completeSegmentScript2 = stringToPrecomputedChunk('","');
 const completeSegmentScript3 = stringToPrecomputedChunk('")</script>');
 
@@ -1700,6 +1711,7 @@ export function writeCompletedSegmentInstruction(
   responseState: ResponseState,
   contentSegmentID: number,
 ): boolean {
+  writeChunk(destination, responseState.startInlineScript);
   if (!responseState.sentCompleteSegmentFunction) {
     // The first time we write this, we'll need to include the full implementation.
     responseState.sentCompleteSegmentFunction = true;
@@ -1718,11 +1730,9 @@ export function writeCompletedSegmentInstruction(
 }
 
 const completeBoundaryScript1Full = stringToPrecomputedChunk(
-  '<script>' + completeBoundaryFunction + ';$RC("',
-);
-const completeBoundaryScript1Partial = stringToPrecomputedChunk(
-  '<script>$RC("',
+  completeBoundaryFunction + ';$RC("',
 );
+const completeBoundaryScript1Partial = stringToPrecomputedChunk('$RC("');
 const completeBoundaryScript2 = stringToPrecomputedChunk('","');
 const completeBoundaryScript3 = stringToPrecomputedChunk('")</script>');
 
@@ -1732,6 +1742,7 @@ export function writeCompletedBoundaryInstruction(
   boundaryID: SuspenseBoundaryID,
   contentSegmentID: number,
 ): boolean {
+  writeChunk(destination, responseState.startInlineScript);
   if (!responseState.sentCompleteBoundaryFunction) {
     // The first time we write this, we'll need to include the full implementation.
     responseState.sentCompleteBoundaryFunction = true;
@@ -1756,16 +1767,17 @@ export function writeCompletedBoundaryInstruction(
 }
 
 const clientRenderScript1Full = stringToPrecomputedChunk(
-  '<script>' + clientRenderFunction + ';$RX("',
+  clientRenderFunction + ';$RX("',
 );
-const clientRenderScript1Partial = stringToPrecomputedChunk('<script>$RX("');
+const clientRenderScript1Partial = stringToPrecomputedChunk('$RX("');
 const clientRenderScript2 = stringToPrecomputedChunk('")</script>');
 
 export function writeClientRenderBoundaryInstruction(
   destination: Destination,
   responseState: ResponseState,
   boundaryID: SuspenseBoundaryID,
 ): boolean {
+  writeChunk(destination, responseState.startInlineScript);
   if (!responseState.sentClientRenderFunction) {
     // The first time we write this, we'll need to include the full implementation.
     responseState.sentClientRenderFunction = true;

filename: packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js
patch: @@ -29,6 +29,7 @@ export const isPrimaryRenderer = false;
 
 export type ResponseState = {
   // Keep this in sync with ReactDOMServerFormatConfig
+  startInlineScript: PrecomputedChunk,
   placeholderPrefix: PrecomputedChunk,
   segmentPrefix: PrecomputedChunk,
   boundaryPrefix: string,
@@ -46,9 +47,10 @@ export function createResponseState(
   generateStaticMarkup: boolean,
   identifierPrefix: string | void,
 ): ResponseState {
-  const responseState = createResponseStateImpl(identifierPrefix);
+  const responseState = createResponseStateImpl(identifierPrefix, undefined);
   return {
     // Keep this in sync with ReactDOMServerFormatConfig
+    startInlineScript: responseState.startInlineScript,
     placeholderPrefix: responseState.placeholderPrefix,
     segmentPrefix: responseState.segmentPrefix,
     boundaryPrefix: responseState.boundaryPrefix,