[Float][Fiber] Enable Float methods to be called outside of render (#26557)

Stacked on #26570 

Previously we restricted Float methods to only being callable while
rendering. This allowed us to make associations between calls and their
position in the DOM tree, for instance hoisting preinitialized styles
into a ShadowRoot or an iframe Document.

When considering how we are going to support Flight support in Float
however it became clear that this restriction would lead to compromises
on the implementation because the Flight client does not execute within
the context of a client render. We want to be able to disaptch Float
directives coming from Flight as soon as possible and this requires
being able to call them outside of render.

this patch modifies Float so that its methods are callable anywhere. The
main consequence of this change is Float will always use the Document
the renderer script is running within as the HoistableRoot. This means
if you preinit as style inside a component render targeting a ShadowRoot
the style will load in the ownerDocument not the ShadowRoot. Practially
speaking it means that preinit is not useful inside ShadowRoots and
iframes.

This tradeoff was deemed acceptable because these methods are
optimistic, not critical. Additionally, the other methods, preconntect,
prefetchDNS, and preload, are not impacted because they already operated
at the level of the ownerDocument and really only interface with the
Network cache layer.

I added a couple additional fixes that were necessary for getting tests
to pass that are worth considering separately.

The first commit improves the diff for `waitForThrow` so it compares
strings if possible.

The second commit makes invokeGuardedCallback not use metaprogramming
pattern and swallows any novel errors produced from trying to run the
guarded callback. Swallowing may not be the best we can do but it at
least protects React against rapid failure when something causes the
dispatchEvent to throw.
This commit is contained in:
Josh Story 2023-04-20 14:40:25 -07:00 committed by GitHub
parent e5708b3ea9
commit fdad813ac7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 77 additions and 164 deletions

View File

@ -479,11 +479,3 @@ export function suspendInstance(type, props) {}
export function waitForCommitToBeReady() {
return null;
}
// eslint-disable-next-line no-undef
export function prepareRendererToRender(container: Container): void {
// noop
}
export function resetRendererAfterRender(): void {
// noop
}

View File

@ -25,8 +25,6 @@ import {ConcurrentMode, NoMode} from 'react-reconciler/src/ReactTypeOfMode';
import hasOwnProperty from 'shared/hasOwnProperty';
import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals.js';
const {Dispatcher} = ReactDOMSharedInternals;
import {
precacheFiberNode,
@ -1936,31 +1934,6 @@ export function prepareToCommitHoistables() {
tagCaches = null;
}
// It is valid to preload even when we aren't actively rendering. For cases where Float functions are
// called when there is no rendering we track the last used document. It is not safe to insert
// arbitrary resources into the lastCurrentDocument b/c it may not actually be the document
// that the resource is meant to apply too (for example stylesheets or scripts). This is only
// appropriate for resources that don't really have a strict tie to the document itself for example
// preloads
let lastCurrentDocument: ?Document = null;
let previousDispatcher = null;
export function prepareRendererToRender(rootContainer: Container) {
if (enableFloat) {
const rootNode = getHoistableRoot(rootContainer);
lastCurrentDocument = getDocumentFromRoot(rootNode);
previousDispatcher = Dispatcher.current;
Dispatcher.current = ReactDOMClientDispatcher;
}
}
export function resetRendererAfterRender() {
if (enableFloat) {
Dispatcher.current = previousDispatcher;
previousDispatcher = null;
}
}
// global collections of Resources
const preloadPropsMap: Map<string, PreloadProps> = new Map();
const preconnectsSet: Set<string> = new Set();
@ -1982,25 +1955,6 @@ function getCurrentResourceRoot(): null | HoistableRoot {
return currentContainer ? getHoistableRoot(currentContainer) : null;
}
// 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
// access to. We prefer the currentDocument if it exists, we also prefer the
// lastCurrentDocument if that exists. As a fallback we will use the window.document
// if available.
function getDocumentForPreloads(): ?Document {
const root = getCurrentResourceRoot();
if (root) {
return root.ownerDocument || root;
} else {
try {
return lastCurrentDocument || window.document;
} catch (error) {
return null;
}
}
}
function getDocumentFromRoot(root: HoistableRoot): Document {
return root.ownerDocument || root;
}
@ -2015,13 +1969,23 @@ export const ReactDOMClientDispatcher = {
preinit,
};
// We expect this to get inlined. It is a function mostly to communicate the special nature of
// how we resolve the HoistableRoot for ReactDOM.pre*() methods. Because we support calling
// these methods outside of render there is no way to know which Document or ShadowRoot is 'scoped'
// and so we have to fall back to something universal. Currently we just refer to the global document.
// This is notable because nowhere else in ReactDOM do we actually reference the global document or window
// because we may be rendering inside an iframe.
function getDocumentForImperativeFloatMethods(): Document {
return document;
}
function preconnectAs(
rel: 'preconnect' | 'dns-prefetch',
crossOrigin: null | '' | 'use-credentials',
href: string,
) {
const ownerDocument = getDocumentForPreloads();
if (typeof href === 'string' && href && ownerDocument) {
const ownerDocument = getDocumentForImperativeFloatMethods();
if (typeof href === 'string' && href) {
const limitedEscapedHref =
escapeSelectorAttributeValueInsideDoubleQuotes(href);
let key = `link[rel="${rel}"][href="${limitedEscapedHref}"]`;
@ -2043,6 +2007,9 @@ function preconnectAs(
}
function prefetchDNS(href: string, options?: mixed) {
if (!enableFloat) {
return;
}
if (__DEV__) {
if (typeof href !== 'string' || !href) {
console.error(
@ -2105,10 +2072,13 @@ type PreloadOptions = {
type?: string,
};
function preload(href: string, options: PreloadOptions) {
if (!enableFloat) {
return;
}
if (__DEV__) {
validatePreloadArguments(href, options);
}
const ownerDocument = getDocumentForPreloads();
const ownerDocument = getDocumentForImperativeFloatMethods();
if (
typeof href === 'string' &&
href &&
@ -2166,9 +2136,13 @@ type PreinitOptions = {
integrity?: string,
};
function preinit(href: string, options: PreinitOptions) {
if (!enableFloat) {
return;
}
if (__DEV__) {
validatePreinitArguments(href, options);
}
const ownerDocument = getDocumentForImperativeFloatMethods();
if (
typeof href === 'string' &&
@ -2176,51 +2150,11 @@ function preinit(href: string, options: PreinitOptions) {
typeof options === 'object' &&
options !== null
) {
const resourceRoot = getCurrentResourceRoot();
const as = options.as;
if (!resourceRoot) {
if (as === 'style' || as === 'script') {
// We are going to emit a preload as a best effort fallback since this preinit
// was called outside of a render. Given the passive nature of this fallback
// we do not warn in dev when props disagree if there happens to already be a
// matching preload with this href
const preloadDocument = getDocumentForPreloads();
if (preloadDocument) {
const limitedEscapedHref =
escapeSelectorAttributeValueInsideDoubleQuotes(href);
const preloadKey = `link[rel="preload"][as="${as}"][href="${limitedEscapedHref}"]`;
let key = preloadKey;
switch (as) {
case 'style':
key = getStyleKey(href);
break;
case 'script':
key = getScriptKey(href);
break;
}
if (!preloadPropsMap.has(key)) {
const preloadProps = preloadPropsFromPreinitOptions(
href,
as,
options,
);
preloadPropsMap.set(key, preloadProps);
if (null === preloadDocument.querySelector(preloadKey)) {
const instance = preloadDocument.createElement('link');
setInitialProperties(instance, 'link', preloadProps);
markNodeAsHoistable(instance);
(preloadDocument.head: any).appendChild(instance);
}
}
}
}
return;
}
switch (as) {
case 'style': {
const styles = getResourcesFromRoot(resourceRoot).hoistableStyles;
const styles = getResourcesFromRoot(ownerDocument).hoistableStyles;
const key = getStyleKey(href);
const precedence = options.precedence || 'default';
@ -2239,7 +2173,7 @@ function preinit(href: string, options: PreinitOptions) {
};
// Attempt to hydrate instance from DOM
let instance: null | Instance = resourceRoot.querySelector(
let instance: null | Instance = ownerDocument.querySelector(
getStylesheetSelectorFromKey(key),
);
if (instance) {
@ -2255,7 +2189,6 @@ function preinit(href: string, options: PreinitOptions) {
if (preloadProps) {
adoptPreloadPropsForStylesheet(stylesheetProps, preloadProps);
}
const ownerDocument = getDocumentFromRoot(resourceRoot);
const link = (instance = ownerDocument.createElement('link'));
markNodeAsHoistable(link);
setInitialProperties(link, 'link', stylesheetProps);
@ -2272,7 +2205,7 @@ function preinit(href: string, options: PreinitOptions) {
});
state.loading |= Inserted;
insertStylesheet(instance, precedence, resourceRoot);
insertStylesheet(instance, precedence, ownerDocument);
}
// Construct a Resource and cache it
@ -2287,7 +2220,7 @@ function preinit(href: string, options: PreinitOptions) {
}
case 'script': {
const src = href;
const scripts = getResourcesFromRoot(resourceRoot).hoistableScripts;
const scripts = getResourcesFromRoot(ownerDocument).hoistableScripts;
const key = getScriptKey(src);
@ -2300,7 +2233,7 @@ function preinit(href: string, options: PreinitOptions) {
}
// Attempt to hydrate instance from DOM
let instance: null | Instance = resourceRoot.querySelector(
let instance: null | Instance = ownerDocument.querySelector(
getScriptSelectorFromKey(key),
);
if (!instance) {
@ -2311,7 +2244,6 @@ function preinit(href: string, options: PreinitOptions) {
if (preloadProps) {
adoptPreloadPropsForScript(scriptProps, preloadProps);
}
const ownerDocument = getDocumentFromRoot(resourceRoot);
instance = ownerDocument.createElement('script');
markNodeAsHoistable(instance);
setInitialProperties(instance, 'link', scriptProps);
@ -2332,20 +2264,6 @@ function preinit(href: string, options: PreinitOptions) {
}
}
function preloadPropsFromPreinitOptions(
href: string,
as: ResourceType,
options: PreinitOptions,
): PreloadProps {
return {
href,
rel: 'preload',
as,
crossOrigin: as === 'font' ? '' : options.crossOrigin,
integrity: options.integrity,
};
}
function stylesheetPropsFromPreinitOptions(
href: string,
precedence: string,

View File

@ -3833,7 +3833,7 @@ body {
});
// @gate enableFloat
it('creates a preload resource when ReactDOM.preinit(..., {as: "style" }) is called outside of render on the client', async () => {
it('creates a stylesheet resource in the ownerDocument when ReactDOM.preinit(..., {as: "style" }) is called outside of render on the client', async () => {
function App() {
React.useEffect(() => {
ReactDOM.preinit('foo', {as: 'style'});
@ -3851,13 +3851,57 @@ body {
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<link rel="preload" href="foo" as="style" />
<link rel="stylesheet" href="foo" data-precedence="default" />
</head>
<body>foo</body>
</html>,
);
});
// @gate enableFloat
it('creates a stylesheet resource in the ownerDocument when ReactDOM.preinit(..., {as: "style" }) is called outside of render on the client', async () => {
// This is testing behavior, but it shows that it is not a good idea to preinit inside a shadowRoot. The point is we are asserting a behavior
// you would want to avoid in a real app.
const shadow = document.body.attachShadow({mode: 'open'});
function ShadowComponent() {
ReactDOM.preinit('bar', {as: 'style'});
return null;
}
function App() {
React.useEffect(() => {
ReactDOM.preinit('foo', {as: 'style'});
}, []);
return (
<html>
<body>
foo
{ReactDOM.createPortal(
<div>
<ShadowComponent />
shadow
</div>,
shadow,
)}
</body>
</html>
);
}
const root = ReactDOMClient.createRoot(document);
root.render(<App />);
await waitForAll([]);
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="bar" data-precedence="default" />
<link rel="stylesheet" href="foo" data-precedence="default" />
</head>
<body>foo</body>
</html>,
);
expect(getMeaningfulChildren(shadow)).toEqual(<div>shadow</div>);
});
// @gate enableFloat
it('creates a script resource when ReactDOM.preinit(..., {as: "script" }) is called', async () => {
function App() {
@ -3955,7 +3999,7 @@ body {
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<link rel="preload" href="foo" as="script" />
<script async="" src="foo" />
</head>
<body>foo</body>
</html>,

View File

@ -247,11 +247,8 @@ export function createRoot(
transitionCallbacks,
);
markContainerAsRoot(root.current, container);
Dispatcher.current = ReactDOMClientDispatcher;
if (enableFloat) {
// Set the default dispatcher to the client dispatcher
Dispatcher.current = ReactDOMClientDispatcher;
}
const rootContainerElement: Document | Element | DocumentFragment =
container.nodeType === COMMENT_NODE
? (container.parentNode: any)
@ -339,10 +336,7 @@ export function hydrateRoot(
transitionCallbacks,
);
markContainerAsRoot(root.current, container);
if (enableFloat) {
// Set the default dispatcher to the client dispatcher
Dispatcher.current = ReactDOMClientDispatcher;
}
Dispatcher.current = ReactDOMClientDispatcher;
// This can't be a comment node since hydration doesn't work on comment nodes anyway.
listenToAllSupportedEvents(container);

View File

@ -489,11 +489,3 @@ export function suspendInstance(type: Type, props: Props): void {}
export function waitForCommitToBeReady(): null {
return null;
}
export function prepareRendererToRender(container: Container): void {
// noop
}
export function resetRendererAfterRender() {
// noop
}

View File

@ -542,11 +542,3 @@ export function suspendInstance(type: Type, props: Props): void {}
export function waitForCommitToBeReady(): null {
return null;
}
export function prepareRendererToRender(container: Container): void {
// noop
}
export function resetRendererAfterRender(): void {
// noop
}

View File

@ -629,9 +629,6 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
},
waitForCommitToBeReady,
prepareRendererToRender() {},
resetRendererAfterRender() {},
};
const hostConfig = useMutation

View File

@ -72,8 +72,6 @@ import {
afterActiveInstanceBlur,
getCurrentEventPriority,
errorHydratingContainer,
prepareRendererToRender,
resetRendererAfterRender,
startSuspendingCommit,
waitForCommitToBeReady,
preloadInstance,
@ -1757,7 +1755,6 @@ export function shouldRemainOnPreviousScreen(): boolean {
}
function pushDispatcher(container: any) {
prepareRendererToRender(container);
const prevDispatcher = ReactCurrentDispatcher.current;
ReactCurrentDispatcher.current = ContextOnlyDispatcher;
if (prevDispatcher === null) {
@ -1771,7 +1768,6 @@ function pushDispatcher(container: any) {
}
function popDispatcher(prevDispatcher: any) {
resetRendererAfterRender();
ReactCurrentDispatcher.current = prevDispatcher;
}

View File

@ -85,8 +85,6 @@ describe('ReactFiberHostContext', () => {
waitForCommitToBeReady() {
return null;
},
prepareRendererToRender: function () {},
resetRendererAfterRender: function () {},
supportsMutation: true,
});

View File

@ -75,8 +75,6 @@ export const preloadInstance = $$$config.preloadInstance;
export const startSuspendingCommit = $$$config.startSuspendingCommit;
export const suspendInstance = $$$config.suspendInstance;
export const waitForCommitToBeReady = $$$config.waitForCommitToBeReady;
export const prepareRendererToRender = $$$config.prepareRendererToRender;
export const resetRendererAfterRender = $$$config.resetRendererAfterRender;
// -------------------
// Microtasks

View File

@ -343,11 +343,3 @@ export function suspendInstance(type: Type, props: Props): void {}
export function waitForCommitToBeReady(): null {
return null;
}
export function prepareRendererToRender(container: Container): void {
// noop
}
export function resetRendererAfterRender(): void {
// noop
}