track resources in different roots separately (#25388)
* track resources in different roots separately * flow types * add test demonstrating portals deep into shadowRoots * revert hostcontext changes * lints * funge style cache key a la ReactDOMComponentTree * hide hacks in componentTree
Josh Story committed
Oct 4, 2022 at 16:11 UTC
2872a26e14627f90e812048f21feb6d0c8cc0244
8 files changed
+220
-43
packages/react-dom-bindings/src/client/ReactDOMComponentTree.js
+10
@@ -7,6 +7,7 @@
7
* @flow
8
*/
9
10
+import type {FloatRoot, StyleResource} from './ReactDOMFloatClient';
11
import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
12
import type {ReactScopeInstance} from 'shared/ReactTypes';
13
import type {
@@ -42,6 +43,7 @@ const internalContainerInstanceKey = '__reactContainer$' + randomKey;
43
const internalEventHandlersKey = '__reactEvents$' + randomKey;
44
const internalEventHandlerListenersKey = '__reactListeners$' + randomKey;
45
const internalEventHandlesSetKey = '__reactHandles$' + randomKey;
46
+const internalRootNodeStylesSetKey = '__reactStyles$' + randomKey;
47
48
export function detachDeletedInstance(node: Instance): void {
49
// TODO: This function is only called on host components. I don't think all of
@@ -266,3 +268,11 @@ export function doesTargetHaveEventHandle(
268
}
269
return eventHandles.has(eventHandle);
270
}
271
+
272
+export function getStylesFromRoot(root: FloatRoot): Map<string, StyleResource> {
273
+ let styles = (root: any)[internalRootNodeStylesSetKey];
274
+ if (!styles) {
275
+ styles = (root: any)[internalRootNodeStylesSetKey] = new Map();
276
+ }
277
+ return styles;
278
+}
packages/react-dom-bindings/src/client/ReactDOMFloatClient.js
+59
-40
@@ -7,9 +7,11 @@
7
* @flow
8
*/
9
10
-import type {Instance} from './ReactDOMHostConfig';
10
+import type {Instance, Container} from './ReactDOMHostConfig';
11
+
12
import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals.js';
13
const {Dispatcher} = ReactDOMSharedInternals;
14
+import {DOCUMENT_NODE} from '../shared/HTMLNodeType';
15
import {
16
validateUnmatchedLinkResourceProps,
17
validatePreloadResourceDifference,
@@ -21,7 +23,9 @@ import {
23
validatePreinitArguments,
24
} from '../shared/ReactDOMResourceValidation';
25
import {createElement, setInitialProperties} from './ReactDOMComponent';
26
+import {getStylesFromRoot} from './ReactDOMComponentTree';
27
import {HTML_NAMESPACE} from '../shared/DOMNamespaces';
28
+import {getCurrentRootHostContainer} from 'react-reconciler/src/ReactFiberHostContext';
29
30
// The resource types we support. currently they match the form for the as argument.
31
// In the future this may need to change, especially when modules / scripts are supported
@@ -47,7 +51,7 @@ type StyleProps = {
51
'data-rprec': string,
52
[string]: mixed,
53
};
50
-type StyleResource = {
54
+export type StyleResource = {
55
type: 'style',
56
57
// Ref count for resource
@@ -66,7 +70,7 @@ type StyleResource = {
70
loaded: boolean,
71
error: mixed,
72
instance: ?Element,
69
- ownerDocument: Document,
73
+ root: FloatRoot,
74
};
75
76
type Props = {[string]: mixed};
@@ -79,11 +83,6 @@ type Resource = StyleResource | PreloadResource;
83
// e = errored
84
type StyleResourceLoadingState = Promise<mixed> & {s?: 'l' | 'e'};
85
82
-// When rendering we set the currentDocument if one exists. we use this for Resources
83
-// we encounter during render. If this is null and we are dispatching preloads and
84
-// other calls on the ReactDOM module we look for the window global and get the document from there
85
-let currentDocument: ?Document = null;
86
-
86
// It is valid to preload even when we aren't actively rendering. For cases where Float functions are
87
// called when there is no rendering we track the last used document. It is not safe to insert
88
// arbitrary resources into the lastCurrentDocument b/c it may not actually be the document
@@ -93,14 +92,17 @@ let currentDocument: ?Document = null;
92
let lastCurrentDocument: ?Document = null;
93
94
let previousDispatcher = null;
96
-export function prepareToRenderResources(ownerDocument: Document) {
97
- currentDocument = lastCurrentDocument = ownerDocument;
95
+export function prepareToRenderResources(rootContainer: Container) {
96
+ // Flot thinks that getRootNode returns a Node but it actually returns a
97
+ // Document or ShadowRoot
98
+ const rootNode: FloatRoot = (rootContainer.getRootNode(): any);
99
+ lastCurrentDocument = getDocumentFromRoot(rootNode);
100
+
101
previousDispatcher = Dispatcher.current;
102
Dispatcher.current = ReactDOMClientDispatcher;
103
}
104
105
export function cleanupAfterRenderResources() {
103
- currentDocument = null;
106
Dispatcher.current = previousDispatcher;
107
previousDispatcher = null;
108
}
@@ -110,9 +112,16 @@ export function cleanupAfterRenderResources() {
112
// from Internals -> ReactDOM -> FloatClient -> Internals so this doesn't introduce a new one.
113
export const ReactDOMClientDispatcher = {preload, preinit};
114
115
+export type FloatRoot = Document | ShadowRoot;
116
+
117
// global maps of Resources
118
const preloadResources: Map<string, PreloadResource> = new Map();
115
-const styleResources: Map<string, StyleResource> = new Map();
119
+
120
+function getCurrentResourceRoot(): null | FloatRoot {
121
+ const currentContainer = getCurrentRootHostContainer();
122
+ // $FlowFixMe flow should know currentContainer is a Node and has getRootNode
123
+ return currentContainer ? currentContainer.getRootNode() : null;
124
+}
125
126
// Preloads are somewhat special. Even if we don't have the Document
127
// used by the root that is rendering a component trying to insert a preload
@@ -121,13 +130,22 @@ const styleResources: Map<string, StyleResource> = new Map();
130
// lastCurrentDocument if that exists. As a fallback we will use the window.document
131
// if available.
132
function getDocumentForPreloads(): ?Document {
124
- try {
125
- return currentDocument || lastCurrentDocument || window.document;
126
- } catch (error) {
127
- return null;
133
+ const root = getCurrentResourceRoot();
134
+ if (root) {
135
+ return root.ownerDocument || root;
136
+ } else {
137
+ try {
138
+ return lastCurrentDocument || window.document;
139
+ } catch (error) {
140
+ return null;
141
+ }
142
}
143
}
144
145
+function getDocumentFromRoot(root: FloatRoot): Document {
146
+ return root.ownerDocument || root;
147
+}
148
+
149
// --------------------------------------
150
// ReactDOM.Preload
151
// --------------------------------------
@@ -200,8 +218,9 @@ function preinit(href: string, options: PreinitOptions) {
218
typeof options === 'object' &&
219
options !== null
220
) {
221
+ const resourceRoot = getCurrentResourceRoot();
222
const as = options.as;
204
- if (!currentDocument) {
223
+ if (!resourceRoot) {
224
// We are going to emit a preload as a best effort fallback since this preinit
225
// was called outside of a render. Given the passive nature of this fallback
226
// we do not warn in dev when props disagree if there happens to already be a
@@ -223,6 +242,7 @@ function preinit(href: string, options: PreinitOptions) {
242
243
switch (as) {
244
case 'style': {
245
+ const styleResources = getStylesFromRoot(resourceRoot);
246
const precedence = options.precedence || 'default';
247
let resource = styleResources.get(href);
248
if (resource) {
@@ -241,8 +261,8 @@ function preinit(href: string, options: PreinitOptions) {
261
options,
262
);
263
resource = createStyleResource(
244
- // $FlowFixMe[incompatible-call] found when upgrading Flow
245
- currentDocument,
264
+ styleResources,
265
+ resourceRoot,
266
href,
267
precedence,
268
resourceProps,
@@ -303,9 +323,10 @@ export function getResource(
323
pendingProps: Props,
324
currentProps: null | Props,
325
): null | Resource {
306
- if (!currentDocument) {
326
+ const resourceRoot = getCurrentResourceRoot();
327
+ if (!resourceRoot) {
328
throw new Error(
308
- '"currentDocument" was expected to exist. This is a bug in React.',
329
+ '"resourceRoot" was expected to exist. This is a bug in React.',
330
);
331
}
332
switch (type) {
@@ -313,6 +334,7 @@ export function getResource(
334
const {rel} = pendingProps;
335
switch (rel) {
336
case 'stylesheet': {
337
+ const styleResources = getStylesFromRoot(resourceRoot);
338
let didWarn;
339
if (__DEV__) {
340
if (currentProps) {
@@ -348,8 +370,8 @@ export function getResource(
370
} else {
371
const resourceProps = stylePropsFromRawProps(styleRawProps);
372
resource = createStyleResource(
351
- // $FlowFixMe[incompatible-call] found when upgrading Flow
352
- currentDocument,
373
+ styleResources,
374
+ resourceRoot,
375
href,
376
precedence,
377
resourceProps,
@@ -384,8 +406,7 @@ export function getResource(
406
} else {
407
const resourceProps = preloadPropsFromRawProps(preloadRawProps);
408
resource = createPreloadResource(
387
- // $FlowFixMe[incompatible-call] found when upgrading Flow
388
- currentDocument,
409
+ getDocumentFromRoot(resourceRoot),
410
href,
411
resourceProps,
412
);
@@ -463,7 +484,8 @@ function createResourceInstance(
484
}
485
486
function createStyleResource(
466
- ownerDocument: Document,
487
+ styleResources: Map<string, StyleResource>,
488
+ root: FloatRoot,
489
href: string,
490
precedence: string,
491
props: StyleProps,
@@ -479,7 +501,7 @@ function createStyleResource(
501
const limitedEscapedHref = escapeSelectorAttributeValueInsideDoubleQuotes(
502
href,
503
);
482
- const existingEl = ownerDocument.querySelector(
504
+ const existingEl = root.querySelector(
505
`link[rel="stylesheet"][href="${limitedEscapedHref}"]`,
506
);
507
const resource = {
@@ -492,7 +514,7 @@ function createStyleResource(
514
preloaded: false,
515
loaded: false,
516
error: false,
495
- ownerDocument,
517
+ root,
518
instance: null,
519
};
520
styleResources.set(href, resource);
@@ -567,7 +589,7 @@ function immediatelyPreloadStyleResource(resource: StyleResource) {
589
const {href, props} = resource;
590
const preloadProps = preloadPropsFromStyleProps(props);
591
resource.hint = createPreloadResource(
570
- resource.ownerDocument,
592
+ getDocumentFromRoot(resource.root),
593
href,
594
preloadProps,
595
);
@@ -613,11 +635,11 @@ function createPreloadResource(
635
636
function acquireStyleResource(resource: StyleResource): Instance {
637
if (!resource.instance) {
616
- const {props, ownerDocument, precedence} = resource;
638
+ const {props, root, precedence} = resource;
639
const limitedEscapedHref = escapeSelectorAttributeValueInsideDoubleQuotes(
640
props.href,
641
);
620
- const existingEl = ownerDocument.querySelector(
642
+ const existingEl = root.querySelector(
643
`link[rel="stylesheet"][data-rprec][href="${limitedEscapedHref}"]`,
644
);
645
if (existingEl) {
@@ -649,11 +671,11 @@ function acquireStyleResource(resource: StyleResource): Instance {
671
const instance = createResourceInstance(
672
'link',
673
resource.props,
652
- ownerDocument,
674
+ getDocumentFromRoot(root),
675
);
676
677
attachLoadListeners(instance, resource);
656
- insertStyleInstance(instance, precedence, ownerDocument);
678
+ insertStyleInstance(instance, precedence, root);
679
resource.instance = instance;
680
}
681
}
@@ -724,11 +746,9 @@ function onResourceError(
746
function insertStyleInstance(
747
instance: Instance,
748
precedence: string,
727
- ownerDocument: Document,
749
+ root: FloatRoot,
750
): void {
729
- const nodes = ownerDocument.querySelectorAll(
730
- 'link[rel="stylesheet"][data-rprec]',
731
- );
751
+ const nodes = root.querySelectorAll('link[rel="stylesheet"][data-rprec]');
752
const last = nodes.length ? nodes[nodes.length - 1] : null;
753
let prior = last;
754
for (let i = 0; i < nodes.length; i++) {
@@ -746,9 +766,8 @@ function insertStyleInstance(
766
// must exist.
767
((prior.parentNode: any): Node).insertBefore(instance, prior.nextSibling);
768
} else {
749
- // @TODO call getRootNode on root.container. if it is a Document, insert into head
750
- // if it is a ShadowRoot insert it into the root node.
751
- const parent = ownerDocument.head;
769
+ const parent =
770
+ root.nodeType === DOCUMENT_NODE ? ((root: any): Document).head : root;
771
if (parent) {
772
parent.insertBefore(instance, parent.firstChild);
773
} else {
packages/react-dom-bindings/src/client/ReactDOMHostConfig.js
+1
-2
@@ -41,7 +41,6 @@ import {
41
warnForDeletedHydratableText,
42
warnForInsertedHydratedElement,
43
warnForInsertedHydratedText,
44
- getOwnerDocumentFromRootContainer,
44
} from './ReactDOMComponent';
45
import {getSelectionInformation, restoreSelection} from './ReactInputSelection';
46
import setTextContent from './setTextContent';
@@ -1376,7 +1375,7 @@ function isHostResourceInstance(instance: Instance | Container): boolean {
1375
1376
export function prepareRendererToRender(rootContainer: Container) {
1377
if (enableFloat) {
1379
- prepareToRenderResources(getOwnerDocumentFromRootContainer(rootContainer));
1378
+ prepareToRenderResources(rootContainer);
1379
}
1380
}
1381
packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+116
@@ -552,6 +552,122 @@ describe('ReactDOMFloat', () => {
552
});
553
});
554
555
+ describe('document encapsulation', () => {
556
+ // @gate enableFloat
557
+ it('can support styles inside portals to a shadowRoot', async () => {
558
+ const shadow = document.body.attachShadow({mode: 'open'});
559
+ const root = ReactDOMClient.createRoot(container);
560
+ root.render(
561
+ <>
562
+ <link rel="stylesheet" href="foo" precedence="default" />
563
+ {ReactDOM.createPortal(
564
+ <div>
565
+ <link
566
+ rel="stylesheet"
567
+ href="foo"
568
+ data-extra-prop="foo"
569
+ precedence="different"
570
+ />
571
+ shadow
572
+ </div>,
573
+ shadow,
574
+ )}
575
+ container
576
+ </>,
577
+ );
578
+ expect(Scheduler).toFlushWithoutYielding();
579
+ expect(getVisibleChildren(document)).toEqual(
580
+ <html>
581
+ <head>
582
+ <link rel="stylesheet" href="foo" data-rprec="default" />
583
+ <link rel="preload" href="foo" as="style" />
584
+ </head>
585
+ <body>
586
+ <div id="container">container</div>
587
+ </body>
588
+ </html>,
589
+ );
590
+ expect(getVisibleChildren(shadow)).toEqual([
591
+ <link
592
+ rel="stylesheet"
593
+ href="foo"
594
+ data-rprec="different"
595
+ data-extra-prop="foo"
596
+ />,
597
+ <div>shadow</div>,
598
+ ]);
599
+ });
600
+ // @gate enableFloat
601
+ it('can support styles inside portals to an element in shadowRoots', async () => {
602
+ const template = document.createElement('template');
603
+ template.innerHTML =
604
+ "<div><div id='shadowcontainer1'></div><div id='shadowcontainer2'></div></div>";
605
+ const shadow = document.body.attachShadow({mode: 'open'});
606
+ shadow.appendChild(template.content);
607
+
608
+ const shadowContainer1 = shadow.getElementById('shadowcontainer1');
609
+ const shadowContainer2 = shadow.getElementById('shadowcontainer2');
610
+ const root = ReactDOMClient.createRoot(container);
611
+ root.render(
612
+ <>
613
+ <link rel="stylesheet" href="foo" precedence="default" />
614
+ {ReactDOM.createPortal(
615
+ <div>
616
+ <link rel="stylesheet" href="foo" precedence="one" />
617
+ <link rel="stylesheet" href="bar" precedence="two" />1
618
+ </div>,
619
+ shadow,
620
+ )}
621
+ {ReactDOM.createPortal(
622
+ <div>
623
+ <link rel="stylesheet" href="foo" precedence="one" />
624
+ <link rel="stylesheet" href="baz" precedence="one" />2
625
+ </div>,
626
+ shadowContainer1,
627
+ )}
628
+ {ReactDOM.createPortal(
629
+ <div>
630
+ <link rel="stylesheet" href="bar" precedence="two" />
631
+ <link rel="stylesheet" href="qux" precedence="three" />3
632
+ </div>,
633
+ shadowContainer2,
634
+ )}
635
+ container
636
+ </>,
637
+ );
638
+ expect(Scheduler).toFlushWithoutYielding();
639
+ expect(getVisibleChildren(document)).toEqual(
640
+ <html>
641
+ <head>
642
+ <link rel="stylesheet" href="foo" data-rprec="default" />
643
+ <link rel="preload" href="foo" as="style" />
644
+ <link rel="preload" href="bar" as="style" />
645
+ <link rel="preload" href="baz" as="style" />
646
+ <link rel="preload" href="qux" as="style" />
647
+ </head>
648
+ <body>
649
+ <div id="container">container</div>
650
+ </body>
651
+ </html>,
652
+ );
653
+ expect(getVisibleChildren(shadow)).toEqual([
654
+ <link rel="stylesheet" href="foo" data-rprec="one" />,
655
+ <link rel="stylesheet" href="baz" data-rprec="one" />,
656
+ <link rel="stylesheet" href="bar" data-rprec="two" />,
657
+ <link rel="stylesheet" href="qux" data-rprec="three" />,
658
+ <div>
659
+ <div id="shadowcontainer1">
660
+ <div>2</div>
661
+ </div>
662
+ <div id="shadowcontainer2">
663
+ <div>3</div>
664
+ </div>
665
+ </div>,
666
+ <div>1</div>,
667
+ ]);
668
+ });
669
+ });
670
+
671
describe('style resources', () => {
672
// @gate enableFloat
673
it('treats link rel stylesheet elements as a style resource when it includes a precedence when server rendering', async () => {
packages/react-reconciler/src/ReactFiberHostContext.js
new
+21
@@ -0,0 +1,21 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+import type {Container} from './ReactFiberHostConfig';
11
+import {enableNewReconciler} from 'shared/ReactFeatureFlags';
12
+
13
+import {getCurrentRootHostContainer as getCurrentRootHostContainer_old} from './ReactFiberHostContext.old';
14
+
15
+import {getCurrentRootHostContainer as getCurrentRootHostContainer_new} from './ReactFiberHostContext.new';
16
+
17
+export function getCurrentRootHostContainer(): null | Container {
18
+ return enableNewReconciler
19
+ ? getCurrentRootHostContainer_new()
20
+ : getCurrentRootHostContainer_old();
21
+}
packages/react-reconciler/src/ReactFiberHostContext.new.js
+6
@@ -38,6 +38,11 @@ function requiredContext<Value>(c: Value | NoContextT): Value {
38
return (c: any);
39
}
40
41
+function getCurrentRootHostContainer(): null | Container {
42
+ const container = rootInstanceStackCursor.current;
43
+ return container === NO_CONTEXT ? null : (container: any);
44
+}
45
+
46
function getRootHostContainer(): Container {
47
const rootInstance = requiredContext(rootInstanceStackCursor.current);
48
return rootInstance;
@@ -101,6 +106,7 @@ function popHostContext(fiber: Fiber): void {
106
}
107
108
export {
109
+ getCurrentRootHostContainer,
110
getHostContext,
111
getRootHostContainer,
112
popHostContainer,
packages/react-reconciler/src/ReactFiberHostContext.old.js
+6
@@ -38,6 +38,11 @@ function requiredContext<Value>(c: Value | NoContextT): Value {
38
return (c: any);
39
}
40
41
+function getCurrentRootHostContainer(): null | Container {
42
+ const container = rootInstanceStackCursor.current;
43
+ return container === NO_CONTEXT ? null : (container: any);
44
+}
45
+
46
function getRootHostContainer(): Container {
47
const rootInstance = requiredContext(rootInstanceStackCursor.current);
48
return rootInstance;
@@ -101,6 +106,7 @@ function popHostContext(fiber: Fiber): void {
106
}
107
108
export {
109
+ getCurrentRootHostContainer,
110
getHostContext,
111
getRootHostContainer,
112
popHostContainer,
scripts/error-codes/codes.json
+1
-1
@@ -431,6 +431,6 @@
431
"443": "acquireResource encountered a resource type it did not expect: \"%s\". this is a bug in React.",
432
"444": "getResource encountered a resource type it did not expect: \"%s\". this is a bug in React.",
433
"445": "\"currentResources\" was expected to exist. This is a bug in React.",
434
- "446": "\"currentDocument\" was expected to exist. This is a bug in React.",
434
+ "446": "\"resourceRoot\" was expected to exist. This is a bug in React.",
435
"447": "While attempting to insert a Resource, React expected the Document to contain a head element but it was not found."
436
}