Skip to content

Commit 5f7ef8c

Browse files
authored
[Float] handle resource Resource creation inside svg context (#25599)
`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.
1 parent 36426e6 commit 5f7ef8c

File tree

4 files changed

+449
-21
lines changed

4 files changed

+449
-21
lines changed

packages/react-dom-bindings/src/client/ReactDOMFloatClient.js

+81-19
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import {
3434
getHostContext,
3535
} from 'react-reconciler/src/ReactFiberHostContext';
3636
import {getResourceFormOnly} from './validateDOMNesting';
37+
import {getNamespace} from './ReactDOMHostConfig';
38+
import {SVG_NAMESPACE} from '../shared/DOMNamespaces';
3739

3840
// The resource types we support. currently they match the form for the as argument.
3941
// In the future this may need to change, especially when modules / scripts are supported
@@ -201,6 +203,28 @@ function getCurrentResourceRoot(): null | FloatRoot {
201203
return currentContainer ? currentContainer.getRootNode() : null;
202204
}
203205

206+
// This resource type constraint can be loosened. It really is everything except PreloadResource
207+
// because that is the only one that does not have an optional instance type. Expand as needed.
208+
function resetInstance(resource: ScriptResource | HeadResource) {
209+
resource.instance = undefined;
210+
}
211+
212+
export function clearRootResources(rootContainer: Container): void {
213+
const rootNode: FloatRoot = (rootContainer.getRootNode(): any);
214+
const resources = getResourcesFromRoot(rootNode);
215+
216+
// We can't actually delete the resource cache because this function is called
217+
// during commit after we have rendered. Instead we detatch any instances from
218+
// the Resource object if they are going to be cleared
219+
220+
// Styles stay put
221+
// Scripts get reset
222+
resources.scripts.forEach(resetInstance);
223+
// Head Resources get reset
224+
resources.head.forEach(resetInstance);
225+
// lastStructuredMeta stays put
226+
}
227+
204228
// Preloads are somewhat special. Even if we don't have the Document
205229
// used by the root that is rendering a component trying to insert a preload
206230
// 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 {
10771101
props,
10781102
root,
10791103
);
1080-
insertResourceInstanceBefore(root, instance, titles.item(0));
1104+
const firstTitle = titles[0];
1105+
insertResourceInstanceBefore(
1106+
root,
1107+
instance,
1108+
firstTitle && firstTitle.namespaceURI !== SVG_NAMESPACE
1109+
? firstTitle
1110+
: null,
1111+
);
10811112
break;
10821113
}
10831114
case 'meta': {
@@ -1397,16 +1428,21 @@ function insertResourceInstanceBefore(
13971428

13981429
export function isHostResourceType(type: string, props: Props): boolean {
13991430
let resourceFormOnly: boolean;
1431+
let namespace: string;
14001432
if (__DEV__) {
14011433
const hostContext = getHostContext();
14021434
resourceFormOnly = getResourceFormOnly(hostContext);
1435+
namespace = getNamespace(hostContext);
14031436
}
14041437
switch (type) {
14051438
case 'base':
1406-
case 'meta':
1407-
case 'title': {
1439+
case 'meta': {
14081440
return true;
14091441
}
1442+
case 'title': {
1443+
const hostContext = getHostContext();
1444+
return getNamespace(hostContext) !== SVG_NAMESPACE;
1445+
}
14101446
case 'link': {
14111447
const {onLoad, onError} = props;
14121448
if (onLoad || onError) {
@@ -1417,6 +1453,11 @@ export function isHostResourceType(type: string, props: Props): boolean {
14171453
' Try removing onLoad={...} and onError={...} or moving it into the root <head> tag or' +
14181454
' somewhere in the <body>.',
14191455
);
1456+
} else if (namespace === SVG_NAMESPACE) {
1457+
console.error(
1458+
'Cannot render a <link> with onLoad or onError listeners as a descendent of <svg>.' +
1459+
' Try removing onLoad={...} and onError={...} or moving it above the <svg> ancestor.',
1460+
);
14201461
}
14211462
}
14221463
return false;
@@ -1426,11 +1467,18 @@ export function isHostResourceType(type: string, props: Props): boolean {
14261467
const {href, precedence, disabled} = props;
14271468
if (__DEV__) {
14281469
validateLinkPropsForStyleResource(props);
1429-
if (typeof precedence !== 'string' && resourceFormOnly) {
1430-
console.error(
1431-
'Cannot render a <link rel="stylesheet" /> outside the main document without knowing its precedence.' +
1432-
' Consider adding precedence="default" or moving it into the root <head> tag.',
1433-
);
1470+
if (typeof precedence !== 'string') {
1471+
if (resourceFormOnly) {
1472+
console.error(
1473+
'Cannot render a <link rel="stylesheet" /> outside the main document without knowing its precedence.' +
1474+
' Consider adding precedence="default" or moving it into the root <head> tag.',
1475+
);
1476+
} else if (namespace === SVG_NAMESPACE) {
1477+
console.error(
1478+
'Cannot render a <link rel="stylesheet" /> as a descendent of an <svg> element without knowing its precedence.' +
1479+
' Consider adding precedence="default" or moving it above the <svg> ancestor.',
1480+
);
1481+
}
14341482
}
14351483
}
14361484
return (
@@ -1450,17 +1498,31 @@ export function isHostResourceType(type: string, props: Props): boolean {
14501498
// precedence with these for style resources
14511499
const {src, async, onLoad, onError} = props;
14521500
if (__DEV__) {
1453-
if (async !== true && resourceFormOnly) {
1454-
console.error(
1455-
'Cannot render a sync or defer <script> outside the main document without knowing its order.' +
1456-
' Try adding async="" or moving it into the root <head> tag.',
1457-
);
1458-
} else if ((onLoad || onError) && resourceFormOnly) {
1459-
console.error(
1460-
'Cannot render a <script> with onLoad or onError listeners outside the main document.' +
1461-
' Try removing onLoad={...} and onError={...} or moving it into the root <head> tag or' +
1462-
' somewhere in the <body>.',
1463-
);
1501+
if (async !== true) {
1502+
if (resourceFormOnly) {
1503+
console.error(
1504+
'Cannot render a sync or defer <script> outside the main document without knowing its order.' +
1505+
' Try adding async="" or moving it into the root <head> tag.',
1506+
);
1507+
} else if (namespace === SVG_NAMESPACE) {
1508+
console.error(
1509+
'Cannot render a sync or defer <script> as a descendent of an <svg> element.' +
1510+
' Try adding async="" or moving it above the ancestor <svg> element.',
1511+
);
1512+
}
1513+
} else if (onLoad || onError) {
1514+
if (resourceFormOnly) {
1515+
console.error(
1516+
'Cannot render a <script> with onLoad or onError listeners outside the main document.' +
1517+
' Try removing onLoad={...} and onError={...} or moving it into the root <head> tag or' +
1518+
' somewhere in the <body>.',
1519+
);
1520+
} else if (namespace === SVG_NAMESPACE) {
1521+
console.error(
1522+
'Cannot render a <script> with onLoad or onError listeners as a descendent of an <svg> element.' +
1523+
' Try removing onLoad={...} and onError={...} or moving it above the ancestor <svg> element.',
1524+
);
1525+
}
14641526
}
14651527
}
14661528
return (async: any) && typeof src === 'string' && !onLoad && !onError;

packages/react-dom-bindings/src/client/ReactDOMHostConfig.js

+32-1
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import {ConcurrentMode, NoMode} from 'react-reconciler/src/ReactTypeOfMode';
8888
import {
8989
prepareToRenderResources,
9090
cleanupAfterRenderResources,
91+
clearRootResources,
9192
isHostResourceType,
9293
} from './ReactDOMFloatClient';
9394

@@ -219,6 +220,16 @@ export function getPublicInstance(instance: Instance): Instance {
219220
return instance;
220221
}
221222

223+
export function getNamespace(hostContext: HostContext): string {
224+
if (__DEV__) {
225+
const hostContextDev: HostContextDev = (hostContext: any);
226+
return hostContextDev.namespace;
227+
} else {
228+
const hostContextProd: HostContextProd = (hostContext: any);
229+
return hostContextProd;
230+
}
231+
}
232+
222233
export function prepareForCommit(containerInfo: Container): Object | null {
223234
eventsEnabled = ReactBrowserEventEmitterIsEnabled();
224235
selectionInformation = getSelectionInformation();
@@ -715,11 +726,18 @@ export function clearContainer(container: Container): void {
715726
if (enableHostSingletons) {
716727
const nodeType = container.nodeType;
717728
if (nodeType === DOCUMENT_NODE) {
729+
clearRootResources(container);
718730
clearContainerSparingly(container);
719731
} else if (nodeType === ELEMENT_NODE) {
720732
switch (container.nodeName) {
733+
case 'HEAD': {
734+
// If we are clearing document.head as a container we are essentially clearing everything
735+
// that was hoisted to the head and should forget the instances that will no longer be in the DOM
736+
clearRootResources(container);
737+
// fall through to clear child contents
738+
}
739+
// eslint-disable-next-line-no-fallthrough
721740
case 'HTML':
722-
case 'HEAD':
723741
case 'BODY':
724742
clearContainerSparingly(container);
725743
return;
@@ -910,6 +928,19 @@ function getNextHydratable(node) {
910928
if (nodeType === ELEMENT_NODE) {
911929
const element: Element = (node: any);
912930
switch (element.tagName) {
931+
// This is subtle. in SVG scope the title tag is case sensitive. we don't want to skip
932+
// titles in svg but we do want to skip them outside of svg. there is an edge case where
933+
// you could do `React.createElement('TITLE', ...)` inside an svg scope but the SSR serializer
934+
// will still emit lowercase. Practically speaking the only time the DOM will have a non-uppercased
935+
// title tagName is if it is inside an svg.
936+
// Other Resource types like META, BASE, LINK, and SCRIPT should be treated as resources even inside
937+
// svg scope because they are invalid otherwise. We still don't need to handle the lowercase variant
938+
// because if they are present in the DOM already they would have been hoisted outside the SVG scope
939+
// as Resources. So while it would be correct to skip a <link> inside <svg> and this algorithm won't
940+
// skip that link because the tagName will not be uppercased it functionally is irrelevant. If one
941+
// tries to render incompatible types such as a non-resource stylesheet inside an svg the server will
942+
// emit that invalid html and hydration will fail. In Dev this will present warnings guiding the
943+
// developer on how to fix.
913944
case 'TITLE':
914945
case 'META':
915946
case 'BASE':

packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js

+4
Original file line numberDiff line numberDiff line change
@@ -1371,6 +1371,7 @@ function pushTitle(
13711371
target: Array<Chunk | PrecomputedChunk>,
13721372
props: Object,
13731373
responseState: ResponseState,
1374+
insertionMode: InsertionMode,
13741375
noscriptTagInScope: boolean,
13751376
): ReactNodeList {
13761377
if (__DEV__) {
@@ -1415,6 +1416,8 @@ function pushTitle(
14151416

14161417
if (
14171418
enableFloat &&
1419+
// title is valid in SVG so we avoid resour
1420+
insertionMode !== SVG_MODE &&
14181421
!noscriptTagInScope &&
14191422
resourcesFromElement('title', props)
14201423
) {
@@ -1926,6 +1929,7 @@ export function pushStartInstance(
19261929
target,
19271930
props,
19281931
responseState,
1932+
formatContext.insertionMode,
19291933
formatContext.noscriptTagInScope,
19301934
)
19311935
: pushStartTitle(target, props, responseState);

0 commit comments

Comments
 (0)