Repository: facebook/react
Pull request: [Float] handle noscript context for Resources

stacked on https://github.com/facebook/react/pull/25569

On the client noscript already never renders children so no resources will be extracted from this context. On the server we now track if we are in a noscript context and turn off Resource semantics in this scope

Changed files:
filename: packages/react-dom-bindings/src/server/ReactDOMFloatServer.js
patch: @@ -863,6 +863,10 @@ export function resourcesFromLink(props: Props): boolean {
     }
   }
   if (props.onLoad || props.onError) {
+    // When a link has these props we can't treat it is a Resource but if we rendered it on the
+    // server it would look like a Resource in the rendered html (the onLoad/onError aren't emitted)
+    // Instead we expect the client to insert them rather than hydrate them which also guarantees
+    // that the onLoad and onError won't fire before the event handlers are attached
     return true;
   }
 

filename: packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js
patch: @@ -274,15 +274,18 @@ type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
 export type FormatContext = {
   insertionMode: InsertionMode, // root/svg/html/mathml/table
   selectedValue: null | string | Array<string>, // the selected value(s) inside a <select>, or null outside <select>
+  noscriptTagInScope: boolean,
 };
 
 function createFormatContext(
   insertionMode: InsertionMode,
   selectedValue: null | string,
+  noscriptTagInScope: boolean,
 ): FormatContext {
   return {
     insertionMode,
     selectedValue,
+    noscriptTagInScope,
   };
 }
 
@@ -293,7 +296,7 @@ export function createRootFormatContext(namespaceURI?: string): FormatContext {
       : namespaceURI === 'http://www.w3.org/1998/Math/MathML'
       ? MATHML_MODE
       : ROOT_HTML_MODE;
-  return createFormatContext(insertionMode, null);
+  return createFormatContext(insertionMode, null, false);
 }
 
 export function getChildFormatContext(
@@ -302,38 +305,77 @@ export function getChildFormatContext(
   props: Object,
 ): FormatContext {
   switch (type) {
+    case 'noscript':
+      return createFormatContext(HTML_MODE, null, true);
     case 'select':
       return createFormatContext(
         HTML_MODE,
         props.value != null ? props.value : props.defaultValue,
+        parentContext.noscriptTagInScope,
       );
     case 'svg':
-      return createFormatContext(SVG_MODE, null);
+      return createFormatContext(
+        SVG_MODE,
+        null,
+        parentContext.noscriptTagInScope,
+      );
     case 'math':
-      return createFormatContext(MATHML_MODE, null);
+      return createFormatContext(
+        MATHML_MODE,
+        null,
+        parentContext.noscriptTagInScope,
+      );
     case 'foreignObject':
-      return createFormatContext(HTML_MODE, null);
+      return createFormatContext(
+        HTML_MODE,
+        null,
+        parentContext.noscriptTagInScope,
+      );
     // Table parents are special in that their children can only be created at all if they're
     // wrapped in a table parent. So we need to encode that we're entering this mode.
     case 'table':
-      return createFormatContext(HTML_TABLE_MODE, null);
+      return createFormatContext(
+        HTML_TABLE_MODE,
+        null,
+        parentContext.noscriptTagInScope,
+      );
     case 'thead':
     case 'tbody':
     case 'tfoot':
-      return createFormatContext(HTML_TABLE_BODY_MODE, null);
+      return createFormatContext(
+        HTML_TABLE_BODY_MODE,
+        null,
+        parentContext.noscriptTagInScope,
+      );
     case 'colgroup':
-      return createFormatContext(HTML_COLGROUP_MODE, null);
+      return createFormatContext(
+        HTML_COLGROUP_MODE,
+        null,
+        parentContext.noscriptTagInScope,
+      );
     case 'tr':
-      return createFormatContext(HTML_TABLE_ROW_MODE, null);
+      return createFormatContext(
+        HTML_TABLE_ROW_MODE,
+        null,
+        parentContext.noscriptTagInScope,
+      );
   }
   if (parentContext.insertionMode >= HTML_TABLE_MODE) {
     // Whatever tag this was, it wasn't a table parent or other special parent, so we must have
     // entered plain HTML again.
-    return createFormatContext(HTML_MODE, null);
+    return createFormatContext(
+      HTML_MODE,
+      null,
+      parentContext.noscriptTagInScope,
+    );
   }
   if (parentContext.insertionMode === ROOT_HTML_MODE) {
     // We've emitted the root and is now in plain HTML mode.
-    return createFormatContext(HTML_MODE, null);
+    return createFormatContext(
+      HTML_MODE,
+      null,
+      parentContext.noscriptTagInScope,
+    );
   }
   return parentContext;
 }
@@ -1155,8 +1197,13 @@ function pushBase(
   props: Object,
   responseState: ResponseState,
   textEmbedded: boolean,
+  noscriptTagInScope: boolean,
 ): ReactNodeList {
-  if (enableFloat && resourcesFromElement('base', props)) {
+  if (
+    enableFloat &&
+    !noscriptTagInScope &&
+    resourcesFromElement('base', props)
+  ) {
     if (textEmbedded) {
       // This link follows text but we aren't writing a tag. while not as efficient as possible we need
       // to be safe and assume text will follow by inserting a textSeparator
@@ -1175,8 +1222,13 @@ function pushMeta(
   props: Object,
   responseState: ResponseState,
   textEmbedded: boolean,
+  noscriptTagInScope: boolean,
 ): ReactNodeList {
-  if (enableFloat && resourcesFromElement('meta', props)) {
+  if (
+    enableFloat &&
+    !noscriptTagInScope &&
+    resourcesFromElement('meta', props)
+  ) {
     if (textEmbedded) {
       // This link follows text but we aren't writing a tag. while not as efficient as possible we need
       // to be safe and assume text will follow by inserting a textSeparator
@@ -1195,8 +1247,9 @@ function pushLink(
   props: Object,
   responseState: ResponseState,
   textEmbedded: boolean,
+  noscriptTagInScope: boolean,
 ): ReactNodeList {
-  if (enableFloat && resourcesFromLink(props)) {
+  if (enableFloat && !noscriptTagInScope && resourcesFromLink(props)) {
     if (textEmbedded) {
       // This link follows text but we aren't writing a tag. while not as efficient as possible we need
       // to be safe and assume text will follow by inserting a textSeparator
@@ -1318,6 +1371,7 @@ function pushTitle(
   target: Array<Chunk | PrecomputedChunk>,
   props: Object,
   responseState: ResponseState,
+  noscriptTagInScope: boolean,
 ): ReactNodeList {
   if (__DEV__) {
     const children = props.children;
@@ -1359,7 +1413,11 @@ function pushTitle(
     }
   }
 
-  if (enableFloat && resourcesFromElement('title', props)) {
+  if (
+    enableFloat &&
+    !noscriptTagInScope &&
+    resourcesFromElement('title', props)
+  ) {
     // We have converted this link exclusively to a resource and no longer
     // need to emit it
     return null;
@@ -1520,8 +1578,9 @@ function pushScript(
   props: Object,
   responseState: ResponseState,
   textEmbedded: boolean,
+  noscriptTagInScope: boolean,
 ): null {
-  if (enableFloat && resourcesFromScript(props)) {
+  if (enableFloat && !noscriptTagInScope && resourcesFromScript(props)) {
     if (textEmbedded) {
       // This link follows text but we aren't writing a tag. while not as efficient as possible we need
       // to be safe and assume text will follow by inserting a textSeparator
@@ -1863,18 +1922,47 @@ export function pushStartInstance(
       return pushStartMenuItem(target, props, responseState);
     case 'title':
       return enableFloat
-        ? pushTitle(target, props, responseState)
+        ? pushTitle(
+            target,
+            props,
+            responseState,
+            formatContext.noscriptTagInScope,
+          )
         : pushStartTitle(target, props, responseState);
     case 'link':
-      return pushLink(target, props, responseState, textEmbedded);
+      return pushLink(
+        target,
+        props,
+        responseState,
+        textEmbedded,
+        formatContext.noscriptTagInScope,
+      );
     case 'script':
       return enableFloat
-        ? pushScript(target, props, responseState, textEmbedded)
+        ? pushScript(
+            target,
+            props,
+            responseState,
+            textEmbedded,
+            formatContext.noscriptTagInScope,
+          )
         : pushStartGenericElement(target, props, type, responseState);
     case 'meta':
-      return pushMeta(target, props, responseState, textEmbedded);
+      return pushMeta(
+        target,
+        props,
+        responseState,
+        textEmbedded,
+        formatContext.noscriptTagInScope,
+      );
     case 'base':
-      return pushBase(target, props, responseState, textEmbedded);
+      return pushBase(
+        target,
+        props,
+        responseState,
+        textEmbedded,
+        formatContext.noscriptTagInScope,
+      );
     // Newline eating tags
     case 'listing':
     case 'pre': {

filename: packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js
patch: @@ -72,6 +72,7 @@ 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,
+    noscriptTagInScope: false,
   };
 }
 

filename: packages/react-dom/src/__tests__/ReactDOMFloat-test.js
patch: @@ -290,6 +290,9 @@ describe('ReactDOMFloat', () => {
             <meta property="foo" content="bar" />
             <link rel="foo" href="bar" onLoad={() => {}} />
             <title>foo</title>
+            <noscript>
+              <link rel="icon" href="icon" />
+            </noscript>
             <base target="foo" href="bar" />
             <script async={true} src="foo" onLoad={() => {}} />
           </head>
@@ -305,6 +308,7 @@ describe('ReactDOMFloat', () => {
           <link rel="preload" href="foo" as="script" />
           <meta property="foo" content="bar" />
           <title>foo</title>
+          <noscript>&lt;link rel="icon" href="icon"/&gt;</noscript>
         </head>
         <body>foo</body>
       </html>,
@@ -317,6 +321,9 @@ describe('ReactDOMFloat', () => {
           <meta property="foo" content="bar" />
           <link rel="foo" href="bar" onLoad={() => {}} />
           <title>foo</title>
+          <noscript>
+            <link rel="icon" href="icon" />
+          </noscript>
           <base target="foo" href="bar" />
           <script async={true} src="foo" onLoad={() => {}} />
         </head>
@@ -332,6 +339,7 @@ describe('ReactDOMFloat', () => {
           <meta property="foo" content="bar" />
           <title>foo</title>
           <link rel="foo" href="bar" />
+          <noscript>&lt;link rel="icon" href="icon"/&gt;</noscript>
           <script async="" src="foo" />
         </head>
         <body>foo</body>
@@ -5376,4 +5384,170 @@ describe('ReactDOMFloat', () => {
       );
     });
   });
+
+  describe('noscript', () => {
+    // @gate enableFloat
+    it('should not turn children of noscript into resources', async () => {
+      function SomeResources() {
+        return (
+          <>
+            <link rel="stylesheet" href="foo" precedence="foo" />
+            <title>foo</title>
+            <link rel="foobar" href="foobar" />
+            <meta charSet="utf-8" />
+            <meta property="og:image" content="foo" />
+            <script async={true} src="script" />
+          </>
+        );
+      }
+      function Indirection({level, children}) {
+        if (level > 0) {
+          return <Indirection level={level - 1}>{children}</Indirection>;
+        } else {
+          return children;
+        }
+      }
+      function App() {
+        return (
+          <html>
+            <head>
+              <SomeResources />
+              <noscript>
+                <SomeResources />
+                <Indirection level={3}>
+                  <SomeResources />
+                </Indirection>
+              </noscript>
+              <SomeResources />
+            </head>
+          </html>
+        );
+      }
+      await actIntoEmptyDocument(() => {
+        const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
+        pipe(writable);
+      });
+
+      expect(getMeaningfulChildren(document)).toEqual(
+        <html>
+          <head>
+            {/* the actual resources */}
+            <meta charset="utf-8" />
+            <link rel="stylesheet" href="foo" data-precedence="foo" />
+            <script async="" src="script" />
+            <title>foo</title>
+            <link rel="foobar" href="foobar" />
+            <meta property="og:image" content="foo" />
+            {/* the noscript children are encoded as a textNode when scripting is enabled */}
+            <noscript>
+              &lt;link rel="stylesheet"
+              href="foo"/&gt;&lt;title&gt;foo&lt;/title&gt;&lt;link rel="foobar"
+              href="foobar"/&gt;&lt;meta charSet="utf-8"/&gt;&lt;meta
+              property="og:image" content="foo"/&gt;&lt;script async=""
+              src="script"&gt;&lt;/script&gt;&lt;link rel="stylesheet"
+              href="foo"/&gt;&lt;title&gt;foo&lt;/title&gt;&lt;link rel="foobar"
+              href="foobar"/&gt;&lt;meta charSet="utf-8"/&gt;&lt;meta
+              property="og:image" content="foo"/&gt;&lt;script async=""
+              src="script"&gt;&lt;/script&gt;
+            </noscript>
+          </head>
+          <body />
+        </html>,
+      );
+
+      const root = ReactDOMClient.hydrateRoot(document, <App />);
+      expect(Scheduler).toFlushWithoutYielding();
+      expect(getMeaningfulChildren(document)).toEqual(
+        <html>
+          <head>
+            {/* the actual resources */}
+            <meta charset="utf-8" />
+            <link rel="stylesheet" href="foo" data-precedence="foo" />
+            <script async="" src="script" />
+            <title>foo</title>
+            <link rel="foobar" href="foobar" />
+            <meta property="og:image" content="foo" />
+            {/* the noscript children are encoded as a textNode when scripting is enabled */}
+            <noscript>
+              &lt;link rel="stylesheet"
+              href="foo"/&gt;&lt;title&gt;foo&lt;/title&gt;&lt;link rel="foobar"
+              href="foobar"/&gt;&lt;meta charSet="utf-8"/&gt;&lt;meta
+              property="og:image" content="foo"/&gt;&lt;script async=""
+              src="script"&gt;&lt;/script&gt;&lt;link rel="stylesheet"
+              href="foo"/&gt;&lt;title&gt;foo&lt;/title&gt;&lt;link rel="foobar"
+              href="foobar"/&gt;&lt;meta charSet="utf-8"/&gt;&lt;meta
+              property="og:image" content="foo"/&gt;&lt;script async=""
+              src="script"&gt;&lt;/script&gt;
+            </noscript>
+          </head>
+          <body />
+        </html>,
+      );
+
+      root.render(null);
+      expect(Scheduler).toFlushWithoutYielding();
+      // stylesheets and scripts currently don't unmount ever
+      // noscript is never hydrated so it also does not get cleared
+      expect(getMeaningfulChildren(document)).toEqual(
+        <html>
+          <head>
+            <link rel="stylesheet" href="foo" data-precedence="foo" />
+            <script async="" src="script" />
+          </head>
+          <body />
+        </html>,
+      );
+    });
+
+    it('noscript runs on the server but does not emit resources and does not run on the client', async () => {
+      function App() {
+        return (
+          <html>
+            <body>
+              <div>
+                foo
+                <noscript>
+                  <Foo />
+                </noscript>
+              </div>
+            </body>
+          </html>
+        );
+      }
+      function Foo() {
+        Scheduler.unstable_yieldValue('Foo');
+
+        return <title>noscript title</title>;
+      }
+
+      await actIntoEmptyDocument(() => {
+        const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
+        pipe(writable);
+      });
+      expect(getMeaningfulChildren(container)).toEqual(
+        <html>
+          <head />
+          <body>
+            <div>
+              foo<noscript>&lt;title&gt;noscript title&lt;/title&gt;</noscript>
+            </div>
+          </body>
+        </html>,
+      );
+      expect(Scheduler).toHaveYielded(['Foo']);
+
+      ReactDOMClient.hydrateRoot(document, <App />);
+      expect(Scheduler).toFlushWithoutYielding();
+      expect(getMeaningfulChildren(document)).toEqual(
+        <html>
+          <head />
+          <body>
+            <div>
+              foo<noscript>&lt;title&gt;noscript title&lt;/title&gt;</noscript>
+            </div>
+          </body>
+        </html>,
+      );
+    });
+  });
 });