Repository: facebook/react
Pull request: [Float] handle resource Resource creation inside svg context

`title` is a valid element descendent of `svg`. this PR adds a prohibition on turning titles in svg into Resources.

This PR also adds additional warnings if you render something that is almost a Resource inside an svg.

Changed files:
filename: packages/react-dom-bindings/src/client/ReactDOMFloatClient.js
patch: @@ -34,6 +34,8 @@ import {
   getHostContext,
 } from 'react-reconciler/src/ReactFiberHostContext';
 import {getResourceFormOnly} from './validateDOMNesting';
+import {getNamespace} from './ReactDOMHostConfig';
+import {SVG_NAMESPACE} from '../shared/DOMNamespaces';
 
 // The resource types we support. currently they match the form for the as argument.
 // In the future this may need to change, especially when modules / scripts are supported
@@ -201,6 +203,28 @@ function getCurrentResourceRoot(): null | FloatRoot {
   return currentContainer ? currentContainer.getRootNode() : null;
 }
 
+// This resource type constraint can be loosened. It really is everything except PreloadResource
+// because that is the only one that does not have an optional instance type. Expand as needed.
+function resetInstance(resource: ScriptResource | HeadResource) {
+  resource.instance = undefined;
+}
+
+export function clearRootResources(rootContainer: Container): void {
+  const rootNode: FloatRoot = (rootContainer.getRootNode(): any);
+  const resources = getResourcesFromRoot(rootNode);
+
+  // We can't actually delete the resource cache because this function is called
+  // during commit after we have rendered. Instead we detatch any instances from
+  // the Resource object if they are going to be cleared
+
+  // Styles stay put
+  // Scripts get reset
+  resources.scripts.forEach(resetInstance);
+  // Head Resources get reset
+  resources.head.forEach(resetInstance);
+  // lastStructuredMeta stays put
+}
+
 // Preloads are somewhat special. Even if we don't have the Document
 // used by the root that is rendering a component trying to insert a preload
 // we can still seed the file cache by doing the preload on any document we have
@@ -1077,7 +1101,14 @@ function acquireHeadResource(resource: HeadResource): Instance {
           props,
           root,
         );
-        insertResourceInstanceBefore(root, instance, titles.item(0));
+        const firstTitle = titles[0];
+        insertResourceInstanceBefore(
+          root,
+          instance,
+          firstTitle && firstTitle.namespaceURI !== SVG_NAMESPACE
+            ? firstTitle
+            : null,
+        );
         break;
       }
       case 'meta': {
@@ -1397,16 +1428,21 @@ function insertResourceInstanceBefore(
 
 export function isHostResourceType(type: string, props: Props): boolean {
   let resourceFormOnly: boolean;
+  let namespace: string;
   if (__DEV__) {
     const hostContext = getHostContext();
     resourceFormOnly = getResourceFormOnly(hostContext);
+    namespace = getNamespace(hostContext);
   }
   switch (type) {
     case 'base':
-    case 'meta':
-    case 'title': {
+    case 'meta': {
       return true;
     }
+    case 'title': {
+      const hostContext = getHostContext();
+      return getNamespace(hostContext) !== SVG_NAMESPACE;
+    }
     case 'link': {
       const {onLoad, onError} = props;
       if (onLoad || onError) {
@@ -1417,6 +1453,11 @@ export function isHostResourceType(type: string, props: Props): boolean {
                 ' Try removing onLoad={...} and onError={...} or moving it into the root <head> tag or' +
                 ' somewhere in the <body>.',
             );
+          } else if (namespace === SVG_NAMESPACE) {
+            console.error(
+              'Cannot render a <link> with onLoad or onError listeners as a descendent of <svg>.' +
+                ' Try removing onLoad={...} and onError={...} or moving it above the <svg> ancestor.',
+            );
           }
         }
         return false;
@@ -1426,11 +1467,18 @@ export function isHostResourceType(type: string, props: Props): boolean {
           const {href, precedence, disabled} = props;
           if (__DEV__) {
             validateLinkPropsForStyleResource(props);
-            if (typeof precedence !== 'string' && resourceFormOnly) {
-              console.error(
-                'Cannot render a <link rel="stylesheet" /> outside the main document without knowing its precedence.' +
-                  ' Consider adding precedence="default" or moving it into the root <head> tag.',
-              );
+            if (typeof precedence !== 'string') {
+              if (resourceFormOnly) {
+                console.error(
+                  'Cannot render a <link rel="stylesheet" /> outside the main document without knowing its precedence.' +
+                    ' Consider adding precedence="default" or moving it into the root <head> tag.',
+                );
+              } else if (namespace === SVG_NAMESPACE) {
+                console.error(
+                  'Cannot render a <link rel="stylesheet" /> as a descendent of an <svg> element without knowing its precedence.' +
+                    ' Consider adding precedence="default" or moving it above the <svg> ancestor.',
+                );
+              }
             }
           }
           return (
@@ -1450,17 +1498,31 @@ export function isHostResourceType(type: string, props: Props): boolean {
       // precedence with these for style resources
       const {src, async, onLoad, onError} = props;
       if (__DEV__) {
-        if (async !== true && resourceFormOnly) {
-          console.error(
-            'Cannot render a sync or defer <script> outside the main document without knowing its order.' +
-              ' Try adding async="" or moving it into the root <head> tag.',
-          );
-        } else if ((onLoad || onError) && resourceFormOnly) {
-          console.error(
-            'Cannot render a <script> with onLoad or onError listeners outside the main document.' +
-              ' Try removing onLoad={...} and onError={...} or moving it into the root <head> tag or' +
-              ' somewhere in the <body>.',
-          );
+        if (async !== true) {
+          if (resourceFormOnly) {
+            console.error(
+              'Cannot render a sync or defer <script> outside the main document without knowing its order.' +
+                ' Try adding async="" or moving it into the root <head> tag.',
+            );
+          } else if (namespace === SVG_NAMESPACE) {
+            console.error(
+              'Cannot render a sync or defer <script> as a descendent of an <svg> element.' +
+                ' Try adding async="" or moving it above the ancestor <svg> element.',
+            );
+          }
+        } else if (onLoad || onError) {
+          if (resourceFormOnly) {
+            console.error(
+              'Cannot render a <script> with onLoad or onError listeners outside the main document.' +
+                ' Try removing onLoad={...} and onError={...} or moving it into the root <head> tag or' +
+                ' somewhere in the <body>.',
+            );
+          } else if (namespace === SVG_NAMESPACE) {
+            console.error(
+              'Cannot render a <script> with onLoad or onError listeners as a descendent of an <svg> element.' +
+                ' Try removing onLoad={...} and onError={...} or moving it above the ancestor <svg> element.',
+            );
+          }
         }
       }
       return (async: any) && typeof src === 'string' && !onLoad && !onError;

filename: packages/react-dom-bindings/src/client/ReactDOMHostConfig.js
patch: @@ -88,6 +88,7 @@ import {ConcurrentMode, NoMode} from 'react-reconciler/src/ReactTypeOfMode';
 import {
   prepareToRenderResources,
   cleanupAfterRenderResources,
+  clearRootResources,
   isHostResourceType,
 } from './ReactDOMFloatClient';
 
@@ -219,6 +220,16 @@ export function getPublicInstance(instance: Instance): Instance {
   return instance;
 }
 
+export function getNamespace(hostContext: HostContext): string {
+  if (__DEV__) {
+    const hostContextDev: HostContextDev = (hostContext: any);
+    return hostContextDev.namespace;
+  } else {
+    const hostContextProd: HostContextProd = (hostContext: any);
+    return hostContextProd;
+  }
+}
+
 export function prepareForCommit(containerInfo: Container): Object | null {
   eventsEnabled = ReactBrowserEventEmitterIsEnabled();
   selectionInformation = getSelectionInformation();
@@ -715,11 +726,18 @@ export function clearContainer(container: Container): void {
   if (enableHostSingletons) {
     const nodeType = container.nodeType;
     if (nodeType === DOCUMENT_NODE) {
+      clearRootResources(container);
       clearContainerSparingly(container);
     } else if (nodeType === ELEMENT_NODE) {
       switch (container.nodeName) {
+        case 'HEAD': {
+          // If we are clearing document.head as a container we are essentially clearing everything
+          // that was hoisted to the head and should forget the instances that will no longer be in the DOM
+          clearRootResources(container);
+          // fall through to clear child contents
+        }
+        // eslint-disable-next-line-no-fallthrough
         case 'HTML':
-        case 'HEAD':
         case 'BODY':
           clearContainerSparingly(container);
           return;
@@ -910,6 +928,19 @@ function getNextHydratable(node) {
       if (nodeType === ELEMENT_NODE) {
         const element: Element = (node: any);
         switch (element.tagName) {
+          // This is subtle. in SVG scope the title tag is case sensitive. we don't want to skip
+          // titles in svg but we do want to skip them outside of svg. there is an edge case where
+          // you could do `React.createElement('TITLE', ...)` inside an svg scope but the SSR serializer
+          // will still emit lowercase. Practically speaking the only time the DOM will have a non-uppercased
+          // title tagName is if it is inside an svg.
+          // Other Resource types like META, BASE, LINK, and SCRIPT should be treated as resources even inside
+          // svg scope because they are invalid otherwise. We still don't need to handle the lowercase variant
+          // because if they are present in the DOM already they would have been hoisted outside the SVG scope
+          // as Resources. So while it would be correct to skip a <link> inside <svg> and this algorithm won't
+          // skip that link because the tagName will not be uppercased it functionally is irrelevant. If one
+          // tries to render incompatible types such as a non-resource stylesheet inside an svg the server will
+          // emit that invalid html and hydration will fail. In Dev this will present warnings guiding the
+          // developer on how to fix.
           case 'TITLE':
           case 'META':
           case 'BASE':

filename: packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js
patch: @@ -1371,6 +1371,7 @@ function pushTitle(
   target: Array<Chunk | PrecomputedChunk>,
   props: Object,
   responseState: ResponseState,
+  insertionMode: InsertionMode,
   noscriptTagInScope: boolean,
 ): ReactNodeList {
   if (__DEV__) {
@@ -1415,6 +1416,8 @@ function pushTitle(
 
   if (
     enableFloat &&
+    // title is valid in SVG so we avoid resour
+    insertionMode !== SVG_MODE &&
     !noscriptTagInScope &&
     resourcesFromElement('title', props)
   ) {
@@ -1926,6 +1929,7 @@ export function pushStartInstance(
             target,
             props,
             responseState,
+            formatContext.insertionMode,
             formatContext.noscriptTagInScope,
           )
         : pushStartTitle(target, props, responseState);

filename: packages/react-dom/src/__tests__/ReactDOMFloat-test.js
patch: @@ -5388,7 +5388,338 @@ describe('ReactDOMFloat', () => {
     });
   });
 
-  describe('noscript', () => {
+  describe('resource free contexts', () => {
+    // @gate enableFloat
+    it('allows resources inside foreignobject within an svg context', async () => {
+      await actIntoEmptyDocument(() => {
+        const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
+          <html>
+            <body>
+              <svg>
+                <foreignObject>
+                  <title>foo</title>
+                </foreignObject>
+              </svg>
+            </body>
+          </html>,
+        );
+        pipe(writable);
+      });
+      expect(getMeaningfulChildren(document)).toEqual(
+        <html>
+          <head>
+            <title>foo</title>
+          </head>
+          <body>
+            <svg>
+              <foreignobject />
+            </svg>
+          </body>
+        </html>,
+      );
+
+      let root = ReactDOMClient.hydrateRoot(
+        document,
+        <html>
+          <body>
+            <svg>
+              <foreignObject>
+                <title>foo</title>
+              </foreignObject>
+            </svg>
+          </body>
+        </html>,
+      );
+      expect(Scheduler).toFlushWithoutYielding();
+      // @TODO the preload should not get inserted on hydration
+      expect(getMeaningfulChildren(document)).toEqual(
+        <html>
+          <head>
+            <title>foo</title>
+          </head>
+          <body>
+            <svg>
+              <foreignobject />
+            </svg>
+          </body>
+        </html>,
+      );
+
+      root.unmount();
+      root = ReactDOMClient.createRoot(document);
+      root.render(
+        <html>
+          <body>
+            <svg>
+              <foreignObject>
+                <title>foo</title>
+              </foreignObject>
+            </svg>
+          </body>
+        </html>,
+      );
+      expect(Scheduler).toFlushWithoutYielding();
+      expect(getMeaningfulChildren(document)).toEqual(
+        <html>
+          <head>
+            <title>foo</title>
+          </head>
+          <body>
+            <svg>
+              <foreignobject />
+            </svg>
+          </body>
+        </html>,
+      );
+    });
+
+    // @gate enableFloat
+    it('warns if you render something that is almost a resource inside an svg tree', async () => {
+      const root = ReactDOMClient.createRoot(container);
+      root.render(
+        <svg>
+          <path>
+            <link rel="stylesheet" href="foo" />
+            <script src="foo" />
+            <script async={true} src="bar" onLoad={() => {}} />
+            <link rel="foo" href="bar" onLoad={() => {}} />
+          </path>
+        </svg>,
+      );
+      expect(() => {
+        expect(Scheduler).toFlushWithoutYielding();
+      }).toErrorDev([
+        'Warning: Cannot render a <link rel="stylesheet" /> as a descendent of an <svg> element without knowing its precedence. Consider adding precedence="default" or moving it above the <svg> ancestor.',
+        'Warning: Cannot render a sync or defer <script> as a descendent of an <svg> element. Try adding async="" or moving it above the ancestor <svg> element.',
+        'Warning: Cannot render a <script> with onLoad or onError listeners as a descendent of an <svg> element. Try removing onLoad={...} and onError={...} or moving it above the ancestor <svg> element.',
+        'Warning: Cannot render a <link> with onLoad or onError listeners as a descendent of <svg>. Try removing onLoad={...} and onError={...} or moving it above the <svg> ancestor.',
+      ]);
+    });
+
+    // @gate enableFloat
+    it('should support non-title resources in svg context', async () => {
+      await actIntoEmptyDocument(() => {
+        const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
+          <html>
+            <body>
+              <svg>
+                <title>foo</title>
+                <link rel="foo" href="bar" />
+                <link rel="stylesheet" href="bar" precedence="default" />
+                <link rel="preload" href="bar" as="style" />
+                <path>
+                  <title>bar</title>
+                  <script src="baz" async={true} />
+                  <meta name="foo" content="bar" />
+                  <base href="foo" />
+                </path>
+              </svg>
+              <title>qux</title>
+            </body>
+          </html>,
+        );
+        pipe(writable);
+      });
+      expect(getMeaningfulChildren(document)).toEqual(
+        <html>
+          <head>
+            <base href="foo" />
+            <link rel="stylesheet" href="bar" data-precedence="default" />
+            <script src="baz" async="" />
+            <link rel="foo" href="bar" />
+            <meta name="foo" content="bar" />
+            <title>qux</title>
+          </head>
+          <body>
+            <svg>
+              <title>foo</title>
+              <path>
+                <title>bar</title>
+              </path>
+            </svg>
+          </body>
+        </html>,
+      );
+
+      let root = ReactDOMClient.hydrateRoot(
+        document,
+        <html>
+          <body>
+            <svg>
+              <title>foo</title>
+              <link rel="foo" href="bar" />
+              <link rel="stylesheet" href="bar" precedence="default" />
+              <link rel="preload" href="bar" as="style" />
+              <path>
+                <title>bar</title>
+                <script src="baz" async={true} />
+                <meta name="foo" content="bar" />
+                <base href="foo" />
+              </path>
+            </svg>
+            <title>qux</title>
+          </body>
+        </html>,
+      );
+      expect(Scheduler).toFlushWithoutYielding();
+      // @TODO the preload should not get inserted on hydration
+      expect(getMeaningfulChildren(document)).toEqual(
+        <html>
+          <head>
+            <base href="foo" />
+            <link rel="stylesheet" href="bar" data-precedence="default" />
+            <script src="baz" async="" />
+            <link rel="foo" href="bar" />
+            <meta name="foo" content="bar" />
+            <title>qux</title>
+            <link rel="preload" href="bar" as="style" />
+          </head>
+          <body>
+            <svg>
+              <title>foo</title>
+              <path>
+                <title>bar</title>
+              </path>
+            </svg>
+          </body>
+        </html>,
+      );
+
+      root.unmount();
+      root = ReactDOMClient.createRoot(document);
+      root.render(
+        <html>
+          <body>
+            <svg>
+              <title>foo</title>
+              <link rel="foo" href="bar" />
+              <link rel="stylesheet" href="bar" precedence="default" />
+              <link rel="preload" href="bar" as="style" />
+              <path>
+                <title>bar</title>
+                <script src="baz" async={true} />
+                <meta name="foo" content="bar" />
+                <base href="foo" />
+              </path>
+            </svg>
+            <title>qux</title>
+          </body>
+        </html>,
+      );
+      expect(Scheduler).toFlushWithoutYielding();
+      expect(getMeaningfulChildren(document)).toEqual(
+        <html>
+          <head>
+            <link rel="stylesheet" href="bar" data-precedence="default" />
+            <link rel="foo" href="bar" />
+            <script src="baz" async="" />
+            <meta name="foo" content="bar" />
+            <base href="foo" />
+            <title>qux</title>
+          </head>
+          <body>
+            <svg>
+              <title>foo</title>
+              <path>
+                <title>bar</title>
+              </path>
+            </svg>
+          </body>
+        </html>,
+      );
+    });
+
+    // @gate enableFloat
+    it('should not treat title descendants of svg into resources', async () => {
+      await actIntoEmptyDocument(() => {
+        const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
+          <html>
+            <body>
+              <svg>
+                <title>foo</title>
+                <path>
+                  <title>bar</title>
+                </path>
+              </svg>
+            </body>
+          </html>,
+        );
+        pipe(writable);
+      });
+      expect(getMeaningfulChildren(document)).toEqual(
+        <html>
+          <head />
+          <body>
+            <svg>
+              <title>foo</title>
+              <path>
+                <title>bar</title>
+              </path>
+            </svg>
+          </body>
+        </html>,
+      );
+
+      let root = ReactDOMClient.hydrateRoot(
+        document,
+        <html>
+          <head />
+          <body>
+            <svg>
+              <title>foo</title>
+              <path>
+                <title>bar</title>
+              </path>
+            </svg>
+          </body>
+        </html>,
+      );
+      expect(Scheduler).toFlushWithoutYielding();
+      expect(getMeaningfulChildren(document)).toEqual(
+        <html>
+          <head />
+          <body>
+            <svg>
+              <title>foo</title>
+              <path>
+                <title>bar</title>
+              </path>
+            </svg>
+          </body>
+        </html>,
+      );
+
+      root.unmount();
+      root = ReactDOMClient.createRoot(document);
+      root.render(
+        <html>
+          <head />
+          <body>
+            <svg>
+              <title>foo</title>
+              <path>
+                <title>bar</title>
+              </path>
+            </svg>
+          </body>
+        </html>,
+      );
+      expect(Scheduler).toFlushWithoutYielding();
+      expect(getMeaningfulChildren(document)).toEqual(
+        <html>
+          <head />
+          <body>
+            <svg>
+              <title>foo</title>
+              <path>
+                <title>bar</title>
+              </path>
+            </svg>
+          </body>
+        </html>,
+      );
+    });
+
     // @gate enableFloat
     it('should not turn children of noscript into resources', async () => {
       function SomeResources() {