From 50d24f948a79e68429bc3a9e4a34fac517840ffb Mon Sep 17 00:00:00 2001 From: Josh Story Date: Sun, 23 Oct 2022 15:03:52 -0700 Subject: [PATCH] [Float] support as Resource (#25546) 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) --- .../src/client/ReactDOMFloatClient.js | 65 ++++++++++++++++++- .../src/server/ReactDOMFloatServer.js | 38 ++++++++++- .../src/server/ReactDOMServerFormatConfig.js | 30 ++++++++- .../src/__tests__/ReactDOMFloat-test.js | 61 ++++++++++++++++- 4 files changed, 187 insertions(+), 7 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js index 1d6a81470889f..dbf899b88cd49 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js +++ b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js @@ -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': { diff --git a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js index 2367eb50a193d..e4f5d822e871e 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js +++ b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js @@ -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, preconnects: Set, fontPreloads: Set, // usedImagePreloads: Set, @@ -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; diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index 421c3a030ed48..4ea7b7a504401 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -1150,6 +1150,26 @@ function pushStartTextArea( return null; } +function pushBase( + target: Array, + 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, props: Object, @@ -1853,6 +1873,8 @@ 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': { @@ -1860,7 +1882,6 @@ export function pushStartInstance( } // 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); diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 42bcd579f38aa..12c18bc3aaaa0 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -1189,8 +1189,67 @@ describe('ReactDOMFloat', () => { , ); }); + + // @gate enableFloat + it('can render as a Resource', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + + +
hello world
+ + , + ); + pipe(writable); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + +
hello world
+ + , + ); + + ReactDOMClient.hydrateRoot( + document, + + + + + + + +
hello world
+ + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + +
hello world
+ + , + ); + }); + // @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( <>