Repository: facebook/react
Pull request: [Float] support <base> as Resource

keys off `target` and `href`.
prepends on insertion similar to title.
only flushes on the server in the shell (should probably add a warning if there are any to flush in a boundary)

Changed files:
filename: packages/react-dom-bindings/src/client/ReactDOMFloatClient.js
patch: @@ -133,9 +133,19 @@ type LinkResource = {
   root: Document,
 };
 
+type BaseResource = {
+  type: 'base',
+  matcher: string,
+  props: Props,
+
+  count: number,
+  instance: ?Element,
+  root: Document,
+};
+
 type Props = {[string]: mixed};
 
-type HeadResource = TitleResource | MetaResource | LinkResource;
+type HeadResource = TitleResource | MetaResource | LinkResource | BaseResource;
 type Resource = StyleResource | ScriptResource | PreloadResource | HeadResource;
 
 export type RootResources = {
@@ -443,6 +453,35 @@ export function getResource(
     );
   }
   switch (type) {
+    case 'base': {
+      const headRoot: Document = getDocumentFromRoot(resourceRoot);
+      const headResources = getResourcesFromRoot(headRoot).head;
+      const {target, href} = pendingProps;
+      let matcher = 'base';
+      matcher +=
+        typeof href === 'string'
+          ? `[href="${escapeSelectorAttributeValueInsideDoubleQuotes(href)}"]`
+          : ':not([href])';
+      matcher +=
+        typeof target === 'string'
+          ? `[target="${escapeSelectorAttributeValueInsideDoubleQuotes(
+              target,
+            )}"]`
+          : ':not([target])';
+      let resource = headResources.get(matcher);
+      if (!resource) {
+        resource = {
+          type: 'base',
+          matcher,
+          props: Object.assign({}, pendingProps),
+          count: 0,
+          instance: null,
+          root: headRoot,
+        };
+        headResources.set(matcher, resource);
+      }
+      return resource;
+    }
     case 'meta': {
       let matcher, propertyString, parentResource;
       const {
@@ -748,6 +787,7 @@ function scriptPropsFromRawProps(rawProps: ScriptQualifyingProps): ScriptProps {
 
 export function acquireResource(resource: Resource): Instance {
   switch (resource.type) {
+    case 'base':
     case 'title':
     case 'link':
     case 'meta': {
@@ -1126,6 +1166,27 @@ function acquireHeadResource(resource: HeadResource): Instance {
         insertResourceInstanceBefore(root, instance, null);
         return instance;
       }
+      case 'base': {
+        const baseResource: BaseResource = (resource: any);
+        const {matcher} = baseResource;
+        const base = root.querySelector(matcher);
+        if (base) {
+          instance = resource.instance = base;
+          markNodeAsResource(instance);
+        } else {
+          instance = resource.instance = createResourceInstance(
+            type,
+            props,
+            root,
+          );
+          insertResourceInstanceBefore(
+            root,
+            instance,
+            root.querySelector('base'),
+          );
+        }
+        return instance;
+      }
       default: {
         throw new Error(
           `acquireHeadResource encountered a resource type it did not expect: "${type}". This is a bug in React.`,
@@ -1341,6 +1402,7 @@ export function isHostResourceType(type: string, props: Props): boolean {
     resourceFormOnly = getResourceFormOnly(hostContext);
   }
   switch (type) {
+    case 'base':
     case 'meta':
     case 'title': {
       return true;
@@ -1403,7 +1465,6 @@ export function isHostResourceType(type: string, props: Props): boolean {
       }
       return (async: any) && typeof src === 'string' && !onLoad && !onError;
     }
-    case 'base':
     case 'template':
     case 'style':
     case 'noscript': {

filename: packages/react-dom-bindings/src/server/ReactDOMFloatServer.js
patch: @@ -101,8 +101,19 @@ type LinkResource = {
   flushed: boolean,
 };
 
+type BaseResource = {
+  type: 'base',
+  props: Props,
+
+  flushed: boolean,
+};
+
 export type Resource = PreloadResource | StyleResource | ScriptResource;
-export type HeadResource = TitleResource | MetaResource | LinkResource;
+export type HeadResource =
+  | TitleResource
+  | MetaResource
+  | LinkResource
+  | BaseResource;
 
 export type Resources = {
   // Request local cache
@@ -113,6 +124,7 @@ export type Resources = {
 
   // Flushing queues for Resource dependencies
   charset: null | MetaResource,
+  bases: Set<BaseResource>,
   preconnects: Set<LinkResource>,
   fontPreloads: Set<PreloadResource>,
   // usedImagePreloads: Set<PreloadResource>,
@@ -144,6 +156,7 @@ export function createResources(): Resources {
 
     // cleared on flush
     charset: null,
+    bases: new Set(),
     preconnects: new Set(),
     fontPreloads: new Set(),
     // usedImagePreloads: new Set(),
@@ -692,9 +705,28 @@ export function resourcesFromElement(type: string, props: Props): boolean {
             resources.headResources.add(resource);
           }
         }
-        return true;
       }
-      return false;
+      return true;
+    }
+    case 'base': {
+      const {target, href} = props;
+      // We mirror the key construction on the client since we will likely unify
+      // this code in the future to better guarantee key semantics are identical
+      // in both environments
+      let key = 'base';
+      key += typeof href === 'string' ? `[href="${href}"]` : ':not([href])';
+      key +=
+        typeof target === 'string' ? `[target="${target}"]` : ':not([target])';
+      if (!resources.headsMap.has(key)) {
+        const resource = {
+          type: 'base',
+          props: Object.assign({}, props),
+          flushed: false,
+        };
+        resources.headsMap.set(key, resource);
+        resources.bases.add(resource);
+      }
+      return true;
     }
   }
   return false;

filename: packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js
patch: @@ -1150,6 +1150,26 @@ function pushStartTextArea(
   return null;
 }
 
+function pushBase(
+  target: Array<Chunk | PrecomputedChunk>,
+  props: Object,
+  responseState: ResponseState,
+  textEmbedded: boolean,
+): ReactNodeList {
+  if (enableFloat && 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
+      target.push(textSeparator);
+    }
+    // We have converted this link exclusively to a resource and no longer
+    // need to emit it
+    return null;
+  }
+
+  return pushSelfClosing(target, props, 'base', responseState);
+}
+
 function pushMeta(
   target: Array<Chunk | PrecomputedChunk>,
   props: Object,
@@ -1853,14 +1873,15 @@ export function pushStartInstance(
         : pushStartGenericElement(target, props, type, responseState);
     case 'meta':
       return pushMeta(target, props, responseState, textEmbedded);
+    case 'base':
+      return pushBase(target, props, responseState, textEmbedded);
     // Newline eating tags
     case 'listing':
     case 'pre': {
       return pushStartPreformattedElement(target, props, type, responseState);
     }
     // Omitted close tags
     case 'area':
-    case 'base':
     case 'br':
     case 'col':
     case 'embed':
@@ -2493,6 +2514,7 @@ export function writeInitialResources(
 
   const {
     charset,
+    bases,
     preconnects,
     fontPreloads,
     precedences,
@@ -2510,6 +2532,12 @@ export function writeInitialResources(
     resources.charset = null;
   }
 
+  bases.forEach(r => {
+    pushSelfClosing(target, r.props, 'base', responseState);
+    r.flushed = true;
+  });
+  bases.clear();
+
   preconnects.forEach(r => {
     // font preload Resources should not already be flushed so we elide this check
     pushLinkImpl(target, r.props, responseState);

filename: packages/react-dom/src/__tests__/ReactDOMFloat-test.js
patch: @@ -1189,8 +1189,67 @@ describe('ReactDOMFloat', () => {
         </html>,
       );
     });
+
+    // @gate enableFloat
+    it('can render <base> as a Resource', async () => {
+      await actIntoEmptyDocument(() => {
+        const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
+          <html>
+            <head />
+            <body>
+              <base target="_blank" />
+              <base href="foo" />
+              <base target="_self" href="bar" />
+              <div>hello world</div>
+            </body>
+          </html>,
+        );
+        pipe(writable);
+      });
+      expect(getMeaningfulChildren(document)).toEqual(
+        <html>
+          <head>
+            <base target="_blank" />
+            <base href="foo" />
+            <base target="_self" href="bar" />
+          </head>
+          <body>
+            <div>hello world</div>
+          </body>
+        </html>,
+      );
+
+      ReactDOMClient.hydrateRoot(
+        document,
+        <html>
+          <head />
+          <body>
+            <base target="_blank" />
+            <base href="foo" />
+            <base target="_self" href="bar" />
+            <base target="_top" href="baz" />
+            <div>hello world</div>
+          </body>
+        </html>,
+      );
+      expect(Scheduler).toFlushWithoutYielding();
+      expect(getMeaningfulChildren(document)).toEqual(
+        <html>
+          <head>
+            <base target="_top" href="baz" />
+            <base target="_blank" />
+            <base href="foo" />
+            <base target="_self" href="bar" />
+          </head>
+          <body>
+            <div>hello world</div>
+          </body>
+        </html>,
+      );
+    });
+
     // @gate enableFloat
-    it('can render icons and apple-touch-icons as resources', async () => {
+    it('can render icons and apple-touch-icons as Resources', async () => {
       await actIntoEmptyDocument(() => {
         const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
           <>