Implement basic stylesheet Resources for react-dom (#25060)

Implement basic support for "Resources". In the context of this commit, the only thing that is currently a Resource are

<link rel="stylesheet" precedence="some-value" ...>

Resources can be rendered anywhere in the react tree, even outside of normal parenting rules, for instance you can render a resource before you have rendered the <html><head> tags for your application. In the stream we reorder this so the browser always receives valid HTML and resources are emitted either in place (normal circumstances) or at the top of the <head> (when you render them above or before the <head> in your react tree)

On the client, resources opt into an entirely different hydration path. Instead of matching the location within the Document these resources are queried for in the entire document. It is an error to have more than one resource with the same href attribute.

The use of precedence here as an opt-in signal for resourcifying the link is in preparation for a more complete Resource implementation which will dedupe resource references (multiple will be valid), hoist to the appropriate container (body, head, or elsewhere), order (according to precedence) and Suspend boundaries that depend on them. More details will come in the coming weeks on this plan.

This feature is gated by an experimental flag and will only be made available in experimental builds until some future time.
This commit is contained in:
Josh Story
2022-08-12 13:27:53 -07:00
committed by GitHub
parent 32baab38f8
commit 796d31809b
24 changed files with 729 additions and 26 deletions
+402 -2
View File
@@ -128,6 +128,8 @@ describe('ReactDOMFizzServer', () => {
buffer = '';
const fakeBody = document.createElement('body');
fakeBody.innerHTML = bufferedContent;
const parent =
container.nodeName === '#document' ? container.body : container;
while (fakeBody.firstChild) {
const node = fakeBody.firstChild;
if (
@@ -137,13 +139,37 @@ describe('ReactDOMFizzServer', () => {
const script = document.createElement('script');
script.textContent = node.textContent;
fakeBody.removeChild(node);
container.appendChild(script);
parent.appendChild(script);
} else {
container.appendChild(node);
parent.appendChild(node);
}
}
}
async function actIntoEmptyDocument(callback) {
await callback();
// Await one turn around the event loop.
// This assumes that we'll flush everything we have so far.
await new Promise(resolve => {
setImmediate(resolve);
});
if (hasErrored) {
throw fatalError;
}
// JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
// We also want to execute any scripts that are embedded.
// We assume that we have now received a proper fragment of HTML.
const bufferedContent = buffer;
// Test Environment
const jsdom = new JSDOM(bufferedContent, {
runScripts: 'dangerously',
});
window = jsdom.window;
document = jsdom.window.document;
container = document;
buffer = '';
}
function getVisibleChildren(element) {
const children = [];
let node = element.firstChild;
@@ -4194,6 +4220,380 @@ describe('ReactDOMFizzServer', () => {
);
});
// @gate enableFloat
it('emits html and head start tags (the preamble) before other content if rendered in the shell', async () => {
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<>
<title data-baz="baz">a title</title>
<html data-foo="foo">
<head data-bar="bar" />
<body>a body</body>
</html>
</>,
);
pipe(writable);
});
expect(getVisibleChildren(document)).toEqual(
<html data-foo="foo">
<head data-bar="bar">
<title data-baz="baz">a title</title>
</head>
<body>a body</body>
</html>,
);
// Hydrate the same thing on the client. We expect this to still fail because <title> is not a Resource
// and is unmatched on hydration
const errors = [];
ReactDOMClient.hydrateRoot(
document,
<>
<title data-baz="baz">a title</title>
<html data-foo="foo">
<head data-bar="bar" />
<body>a body</body>
</html>
</>,
{
onRecoverableError: (err, errInfo) => {
errors.push(err.message);
},
},
);
expect(() => {
try {
expect(() => {
expect(Scheduler).toFlushWithoutYielding();
}).toThrow('Invalid insertion of HTML node in #document node.');
} catch (e) {
console.log('e', e);
}
}).toErrorDev(
[
'Warning: Expected server HTML to contain a matching <title> in <#document>.',
'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.',
'Warning: validateDOMNesting(...): <title> cannot appear as a child of <#document>',
],
{withoutStack: 1},
);
expect(errors).toEqual([
'Hydration failed because the initial UI does not match what was rendered on the server.',
'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
]);
expect(getVisibleChildren(document)).toEqual();
expect(() => {
expect(Scheduler).toFlushWithoutYielding();
}).toThrow('The node to be removed is not a child of this node.');
});
// @gate enableFloat
it('holds back body and html closing tags (the postamble) until all pending tasks are completed', async () => {
const chunks = [];
writable.on('data', chunk => {
chunks.push(chunk);
});
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<html>
<head />
<body>
first
<Suspense>
<AsyncText text="second" />
</Suspense>
</body>
</html>,
);
pipe(writable);
});
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body>{'first'}</body>
</html>,
);
await act(() => {
resolveText('second');
});
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body>
{'first'}
{'second'}
</body>
</html>,
);
expect(chunks.pop()).toEqual('</body></html>');
});
// @gate enableFloat
it('recognizes stylesheet links as attributes during hydration', async () => {
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<>
<link rel="stylesheet" href="foo" precedence="default" />
<html>
<head>
<link rel="author" precedence="this is a nonsense prop" />
</head>
<body>a body</body>
</html>
</>,
);
pipe(writable);
});
// precedence for stylesheets is mapped to a valid data attribute that is recognized on the client
// as opting this node into resource semantics. the use of precedence on the author link is just a
// non standard attribute which React allows but is not given any special treatment.
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="foo" data-rprec="default" />
<link rel="author" precedence="this is a nonsense prop" />
</head>
<body>a body</body>
</html>,
);
// It hydrates successfully
const root = ReactDOMClient.hydrateRoot(
document,
<>
<link rel="stylesheet" href="foo" precedence="default" />
<html>
<head>
<link rel="author" precedence="this is a nonsense prop" />
</head>
<body>a body</body>
</html>
</>,
);
// We manually capture uncaught errors b/c Jest does not play well with errors thrown in
// microtasks after the test completes even when it is expecting to fail (e.g. when the gate is false)
// We need to flush the scheduler at the end even if there was an earlier throw otherwise this test will
// fail even when failure is expected. This is primarily caused by invokeGuardedCallback replaying commit
// phase errors which get rethrown in a microtask
const uncaughtErrors = [];
try {
expect(Scheduler).toFlushWithoutYielding();
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="foo" data-rprec="default" />
<link rel="author" precedence="this is a nonsense prop" />
</head>
<body>a body</body>
</html>,
);
} catch (e) {
uncaughtErrors.push(e);
}
try {
expect(Scheduler).toFlushWithoutYielding();
} catch (e) {
uncaughtErrors.push(e);
}
root.render(
<>
<link rel="stylesheet" href="foo" precedence="default" data-bar="bar" />
<html>
<head />
<body>a body</body>
</html>
</>,
);
try {
expect(Scheduler).toFlushWithoutYielding();
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<link
rel="stylesheet"
href="foo"
data-rprec="default"
data-bar="bar"
/>
</head>
<body>a body</body>
</html>,
);
} catch (e) {
uncaughtErrors.push(e);
}
try {
expect(Scheduler).toFlushWithoutYielding();
} catch (e) {
uncaughtErrors.push(e);
}
if (uncaughtErrors.length > 0) {
throw uncaughtErrors[0];
}
});
// Temporarily this test is expected to fail everywhere. When we have resource hoisting
// it should start to pass and we can adjust the gate accordingly
// @gate false && enableFloat
it('should insert missing resources during hydration', async () => {
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<html>
<body>foo</body>
</html>,
);
pipe(writable);
});
const uncaughtErrors = [];
ReactDOMClient.hydrateRoot(
document,
<>
<link rel="stylesheet" href="foo" precedence="foo" />
<html>
<head />
<body>foo</body>
</html>
</>,
);
try {
expect(Scheduler).toFlushWithoutYielding();
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="foo" precedence="foo" />
</head>
<body>foo</body>
</html>,
);
} catch (e) {
uncaughtErrors.push(e);
}
// need to flush again to get the invoke guarded callback error to throw in microtask
try {
expect(Scheduler).toFlushWithoutYielding();
} catch (e) {
uncaughtErrors.push(e);
}
if (uncaughtErrors.length) {
throw uncaughtErrors[0];
}
});
// @gate experimental && enableFloat
it('fail hydration if a suitable resource cannot be found in the DOM for a given location (href)', async () => {
gate(flags => {
if (!(__EXPERIMENTAL__ && flags.enableFloat)) {
throw new Error('bailing out of test');
}
});
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<html>
<head />
<body>a body</body>
</html>,
);
pipe(writable);
});
const errors = [];
ReactDOMClient.hydrateRoot(
document,
<html>
<head>
<link rel="stylesheet" href="foo" precedence="low" />
</head>
<body>a body</body>
</html>,
{
onRecoverableError(err, errInfo) {
errors.push(err.message);
},
},
);
expect(() => {
expect(Scheduler).toFlushWithoutYielding();
}).toErrorDev(
[
'Warning: A matching Hydratable Resource was not found in the DOM for <link rel="stylesheet" href="foo">',
'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.',
],
{withoutStack: 1},
);
expect(errors).toEqual([
'Hydration failed because the initial UI does not match what was rendered on the server.',
'Hydration failed because the initial UI does not match what was rendered on the server.',
'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
]);
});
// @gate experimental && enableFloat
it('should error in dev when rendering more than one resource for a given location (href)', async () => {
gate(flags => {
if (!(__EXPERIMENTAL__ && flags.enableFloat)) {
throw new Error('bailing out of test');
}
});
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<>
<link rel="stylesheet" href="foo" precedence="low" />
<link rel="stylesheet" href="foo" precedence="high" />
<html>
<head />
<body>a body</body>
</html>
</>,
);
pipe(writable);
});
expect(getVisibleChildren(document)).toEqual(
<html>
<head>
<link rel="stylesheet" href="foo" data-rprec="low" />
<link rel="stylesheet" href="foo" data-rprec="high" />
</head>
<body>a body</body>
</html>,
);
const errors = [];
ReactDOMClient.hydrateRoot(
document,
<>
<html>
<head>
<link rel="stylesheet" href="foo" precedence="low" />
<link rel="stylesheet" href="foo" precedence="high" />
</head>
<body>a body</body>
</html>
</>,
{
onRecoverableError(err, errInfo) {
errors.push(err.message);
},
},
);
expect(() => {
expect(Scheduler).toFlushWithoutYielding();
}).toErrorDev([
'Warning: Stylesheet resources need a unique representation in the DOM while hydrating and more than one matching DOM Node was found. To fix, ensure you are only rendering one stylesheet link with an href attribute of "foo"',
'Warning: Stylesheet resources need a unique representation in the DOM while hydrating and more than one matching DOM Node was found. To fix, ensure you are only rendering one stylesheet link with an href attribute of "foo"',
]);
expect(errors).toEqual([]);
});
describe('text separators', () => {
// To force performWork to start before resolving AsyncText but before piping we need to wait until
// after scheduleWork which currently uses setImmediate to delay performWork
+1
View File
@@ -358,6 +358,7 @@ describe('ReactDOMRoot', () => {
);
});
// @gate !__DEV__ || !enableFloat
it('warns if updating a root that has had its contents removed', async () => {
const root = ReactDOMClient.createRoot(container);
root.render(<div>Hi</div>);
+13 -1
View File
@@ -73,6 +73,7 @@ import {
enableTrustedTypesIntegration,
enableCustomElementPropertySupport,
enableClientRenderFallbackOnTextMismatch,
enableFloat,
} from 'shared/ReactFeatureFlags';
import {
mediaEventTypes,
@@ -257,7 +258,7 @@ export function checkForUnmatchedText(
}
}
function getOwnerDocumentFromRootContainer(
export function getOwnerDocumentFromRootContainer(
rootContainerElement: Element | Document | DocumentFragment,
): Document {
return rootContainerElement.nodeType === DOCUMENT_NODE
@@ -1018,6 +1019,17 @@ export function diffHydratedProperties(
: getPropertyInfo(propKey);
if (rawProps[SUPPRESS_HYDRATION_WARNING] === true) {
// Don't bother comparing. We're ignoring all these warnings.
} else if (
enableFloat &&
tag === 'link' &&
rawProps.rel === 'stylesheet' &&
propKey === 'precedence'
) {
// @TODO this is a temporary rule while we haven't implemented HostResources yet. This is used to allow
// for hydrating Resources (at the moment, stylesheets with a precedence prop) by using a data attribute.
// When we implement HostResources there will be no hydration directly so this code can be deleted
// $FlowFixMe - Should be inferred as not undefined.
extraAttributeNames.delete('data-rprec');
} else if (
propKey === SUPPRESS_CONTENT_EDITABLE_WARNING ||
propKey === SUPPRESS_HYDRATION_WARNING ||
+65 -3
View File
@@ -40,6 +40,7 @@ import {
warnForDeletedHydratableText,
warnForInsertedHydratedElement,
warnForInsertedHydratedText,
getOwnerDocumentFromRootContainer,
} from './ReactDOMComponent';
import {getSelectionInformation, restoreSelection} from './ReactInputSelection';
import setTextContent from './setTextContent';
@@ -64,6 +65,7 @@ import {retryIfBlockedOn} from '../events/ReactDOMEventReplaying';
import {
enableCreateEventHandleAPI,
enableScopeAPI,
enableFloat,
} from 'shared/ReactFeatureFlags';
import {HostComponent, HostText} from 'react-reconciler/src/ReactWorkTags';
import {listenToAllSupportedEvents} from '../events/DOMPluginEventSystem';
@@ -675,6 +677,14 @@ export function clearContainer(container: Container): void {
export const supportsHydration = true;
export function isHydratableResource(type: string, props: Props) {
return (
type === 'link' &&
typeof (props: any).precedence === 'string' &&
(props: any).rel === 'stylesheet'
);
}
export function canHydrateInstance(
instance: HydratableInstance,
type: string,
@@ -769,10 +779,25 @@ export function registerSuspenseInstanceRetry(
function getNextHydratable(node) {
// Skip non-hydratable nodes.
for (; node != null; node = node.nextSibling) {
for (; node != null; node = ((node: any): Node).nextSibling) {
const nodeType = node.nodeType;
if (nodeType === ELEMENT_NODE || nodeType === TEXT_NODE) {
break;
if (enableFloat) {
if (nodeType === ELEMENT_NODE) {
if (
((node: any): Element).tagName === 'LINK' &&
((node: any): Element).hasAttribute('data-rprec')
) {
continue;
}
break;
}
if (nodeType === TEXT_NODE) {
break;
}
} else {
if (nodeType === ELEMENT_NODE || nodeType === TEXT_NODE) {
break;
}
}
if (nodeType === COMMENT_NODE) {
const nodeData = (node: any).data;
@@ -873,6 +898,43 @@ export function hydrateSuspenseInstance(
precacheFiberNode(internalInstanceHandle, suspenseInstance);
}
export function getMatchingResourceInstance(
type: string,
props: Props,
rootHostContainer: Container,
): ?Instance {
if (enableFloat) {
switch (type) {
case 'link': {
if (typeof (props: any).href !== 'string') {
return null;
}
const selector = `link[rel="stylesheet"][data-rprec][href="${
(props: any).href
}"]`;
const link = getOwnerDocumentFromRootContainer(
rootHostContainer,
).querySelector(selector);
if (__DEV__) {
const allLinks = getOwnerDocumentFromRootContainer(
rootHostContainer,
).querySelectorAll(selector);
if (allLinks.length > 1) {
console.error(
'Stylesheet resources need a unique representation in the DOM while hydrating' +
' and more than one matching DOM Node was found. To fix, ensure you are only' +
' rendering one stylesheet link with an href attribute of "%s".',
(props: any).href,
);
}
}
return link;
}
}
}
return null;
}
export function getNextHydratableInstanceAfterSuspenseInstance(
suspenseInstance: SuspenseInstance,
): null | HydratableInstance {
+2 -1
View File
@@ -15,6 +15,7 @@ import type {
import {queueExplicitHydrationTarget} from '../events/ReactDOMEventReplaying';
import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
import {enableFloat} from 'shared/ReactFeatureFlags';
export type RootType = {
render(children: ReactNodeList): void,
@@ -118,7 +119,7 @@ ReactDOMHydrationRoot.prototype.render = ReactDOMRoot.prototype.render = functio
const container = root.containerInfo;
if (container.nodeType !== COMMENT_NODE) {
if (!enableFloat && container.nodeType !== COMMENT_NODE) {
const hostInstance = findHostInstanceWithNoPortals(root.current);
if (hostInstance) {
if (hostInstance.parentNode !== container) {
+101 -8
View File
@@ -20,6 +20,7 @@ import {Children} from 'react';
import {
enableFilterEmptyStringAttributesDOM,
enableCustomElementPropertySupport,
enableFloat,
} from 'shared/ReactFeatureFlags';
import type {
@@ -1056,6 +1057,52 @@ function pushStartTextArea(
return null;
}
function pushLink(
target: Array<Chunk | PrecomputedChunk>,
props: Object,
responseState: ResponseState,
): ReactNodeList {
const isStylesheet = props.rel === 'stylesheet';
target.push(startChunkForTag('link'));
for (const propKey in props) {
if (hasOwnProperty.call(props, propKey)) {
const propValue = props[propKey];
if (propValue == null) {
continue;
}
switch (propKey) {
case 'children':
case 'dangerouslySetInnerHTML':
throw new Error(
`${'link'} is a self-closing tag and must neither have \`children\` nor ` +
'use `dangerouslySetInnerHTML`.',
);
case 'precedence': {
if (isStylesheet) {
if (propValue === true || typeof propValue === 'string') {
pushAttribute(target, responseState, 'data-rprec', propValue);
} else if (__DEV__) {
throw new Error(
`the "precedence" prop for links to stylehseets expects to receive a string but received something of type "${typeof propValue}" instead.`,
);
}
break;
}
// intentionally fall through
}
// eslint-disable-next-line-no-fallthrough
default:
pushAttribute(target, responseState, propKey, propValue);
break;
}
}
}
target.push(endOfStartTagSelfClosing);
return null;
}
function pushSelfClosing(
target: Array<Chunk | PrecomputedChunk>,
props: Object,
@@ -1189,6 +1236,39 @@ function pushStartTitle(
return children;
}
function pushStartHead(
target: Array<Chunk | PrecomputedChunk>,
preamble: ?Array<Chunk | PrecomputedChunk>,
props: Object,
tag: string,
responseState: ResponseState,
): ReactNodeList {
// Preamble type is nullable for feature off cases but is guaranteed when feature is on
target = enableFloat ? (preamble: any) : target;
return pushStartGenericElement(target, props, tag, responseState);
}
function pushStartHtml(
target: Array<Chunk | PrecomputedChunk>,
preamble: ?Array<Chunk | PrecomputedChunk>,
props: Object,
tag: string,
formatContext: FormatContext,
responseState: ResponseState,
): ReactNodeList {
// Preamble type is nullable for feature off cases but is guaranteed when feature is on
target = enableFloat ? (preamble: any) : target;
if (formatContext.insertionMode === ROOT_HTML_MODE) {
// If we're rendering the html tag and we're at the root (i.e. not in foreignObject)
// then we also emit the DOCTYPE as part of the root content as a convenience for
// rendering the whole document.
target.push(DOCTYPE);
}
return pushStartGenericElement(target, props, tag, responseState);
}
function pushStartGenericElement(
target: Array<Chunk | PrecomputedChunk>,
props: Object,
@@ -1405,6 +1485,7 @@ const DOCTYPE: PrecomputedChunk = stringToPrecomputedChunk('<!DOCTYPE html>');
export function pushStartInstance(
target: Array<Chunk | PrecomputedChunk>,
preamble: ?Array<Chunk | PrecomputedChunk>,
type: string,
props: Object,
responseState: ResponseState,
@@ -1461,6 +1542,8 @@ export function pushStartInstance(
return pushStartMenuItem(target, props, responseState);
case 'title':
return pushStartTitle(target, props, responseState);
case 'link':
return pushLink(target, props, responseState);
// Newline eating tags
case 'listing':
case 'pre': {
@@ -1475,7 +1558,6 @@ export function pushStartInstance(
case 'hr':
case 'img':
case 'keygen':
case 'link':
case 'meta':
case 'param':
case 'source':
@@ -1495,14 +1577,18 @@ export function pushStartInstance(
case 'missing-glyph': {
return pushStartGenericElement(target, props, type, responseState);
}
// Preamble start tags
case 'head':
return pushStartHead(target, preamble, props, type, responseState);
case 'html': {
if (formatContext.insertionMode === ROOT_HTML_MODE) {
// If we're rendering the html tag and we're at the root (i.e. not in foreignObject)
// then we also emit the DOCTYPE as part of the root content as a convenience for
// rendering the whole document.
target.push(DOCTYPE);
}
return pushStartGenericElement(target, props, type, responseState);
return pushStartHtml(
target,
preamble,
props,
type,
formatContext,
responseState,
);
}
default: {
if (type.indexOf('-') === -1 && typeof props.is !== 'string') {
@@ -1521,6 +1607,7 @@ const endTag2 = stringToPrecomputedChunk('>');
export function pushEndInstance(
target: Array<Chunk | PrecomputedChunk>,
postamble: ?Array<Chunk | PrecomputedChunk>,
type: string,
props: Object,
): void {
@@ -1546,6 +1633,12 @@ export function pushEndInstance(
// No close tag needed.
break;
}
// Postamble end tags
case 'body':
case 'html':
// Preamble type is nullable for feature off cases but is guaranteed when feature is on
target = enableFloat ? (postamble: any) : target;
// Intentional fallthrough
default: {
target.push(endTag1, stringToChunk(type), endTag2);
}
@@ -137,6 +137,7 @@ export function pushTextInstance(
export function pushStartInstance(
target: Array<Chunk | PrecomputedChunk>,
preamble: ?Array<Chunk | PrecomputedChunk>,
type: string,
props: Object,
responseState: ResponseState,
@@ -153,6 +154,7 @@ export function pushStartInstance(
export function pushEndInstance(
target: Array<Chunk | PrecomputedChunk>,
postamble: ?Array<Chunk | PrecomputedChunk>,
type: string,
props: Object,
): void {
+2
View File
@@ -113,6 +113,7 @@ const ReactNoopServer = ReactFizzServer({
},
pushStartInstance(
target: Array<Uint8Array>,
preamble: Array<Uint8Array>,
type: string,
props: Object,
): ReactNodeList {
@@ -128,6 +129,7 @@ const ReactNoopServer = ReactFizzServer({
pushEndInstance(
target: Array<Uint8Array>,
postamble: Array<Uint8Array>,
type: string,
props: Object,
): void {
@@ -24,10 +24,12 @@ export const supportsHydration = false;
export const canHydrateInstance = shim;
export const canHydrateTextInstance = shim;
export const canHydrateSuspenseInstance = shim;
export const isHydratableResource = shim;
export const isSuspenseInstancePending = shim;
export const isSuspenseInstanceFallback = shim;
export const getSuspenseInstanceFallbackErrorDetails = shim;
export const registerSuspenseInstanceRetry = shim;
export const getMatchingResourceInstance = shim;
export const getNextHydratableSibling = shim;
export const getFirstHydratableChild = shim;
export const getFirstHydratableChildWithinContainer = shim;
@@ -34,6 +34,7 @@ import {
NoFlags,
DidCapture,
} from './ReactFiberFlags';
import {enableFloat} from 'shared/ReactFeatureFlags';
import {
createFiberFromHostInstanceForDeletion,
@@ -45,7 +46,9 @@ import {
canHydrateInstance,
canHydrateTextInstance,
canHydrateSuspenseInstance,
isHydratableResource,
getNextHydratableSibling,
getMatchingResourceInstance,
getFirstHydratableChild,
getFirstHydratableChildWithinContainer,
getFirstHydratableChildWithinSuspenseInstance,
@@ -75,6 +78,7 @@ import {
restoreSuspendedTreeContext,
} from './ReactFiberTreeContext.new';
import {queueRecoverableErrors} from './ReactFiberWorkLoop.new';
import {getRootHostContainer} from './ReactFiberHostContext.new';
// The deepest Fiber on the stack involved in a hydration context.
// This may have been an insertion or a hydration.
@@ -404,6 +408,19 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
if (!isHydrating) {
return;
}
if (enableFloat) {
if (
fiber.tag === HostComponent &&
isHydratableResource(fiber.type, fiber.pendingProps)
) {
fiber.stateNode = getMatchingResourceInstance(
fiber.type,
fiber.pendingProps,
getRootHostContainer(),
);
return;
}
}
let nextInstance = nextHydratableInstance;
if (!nextInstance) {
if (shouldClientRenderOnMismatch(fiber)) {
@@ -596,6 +613,30 @@ function popHydrationState(fiber: Fiber): boolean {
if (!supportsHydration) {
return false;
}
if (
enableFloat &&
isHydrating &&
isHydratableResource(fiber.type, fiber.memoizedProps)
) {
if (fiber.stateNode === null) {
if (__DEV__) {
const rel = fiber.memoizedProps.rel
? `rel="${fiber.memoizedProps.rel}" `
: '';
const href = fiber.memoizedProps.href
? `href="${fiber.memoizedProps.href}"`
: '';
console.error(
'A matching Hydratable Resource was not found in the DOM for <%s %s%s>.',
fiber.type,
rel,
href,
);
}
throwOnHydrationMismatch(fiber);
}
return true;
}
if (fiber !== hydrationParentFiber) {
// We're deeper than the current hydration context, inside an inserted
// tree.
@@ -34,6 +34,7 @@ import {
NoFlags,
DidCapture,
} from './ReactFiberFlags';
import {enableFloat} from 'shared/ReactFeatureFlags';
import {
createFiberFromHostInstanceForDeletion,
@@ -45,7 +46,9 @@ import {
canHydrateInstance,
canHydrateTextInstance,
canHydrateSuspenseInstance,
isHydratableResource,
getNextHydratableSibling,
getMatchingResourceInstance,
getFirstHydratableChild,
getFirstHydratableChildWithinContainer,
getFirstHydratableChildWithinSuspenseInstance,
@@ -75,6 +78,7 @@ import {
restoreSuspendedTreeContext,
} from './ReactFiberTreeContext.old';
import {queueRecoverableErrors} from './ReactFiberWorkLoop.old';
import {getRootHostContainer} from './ReactFiberHostContext.old';
// The deepest Fiber on the stack involved in a hydration context.
// This may have been an insertion or a hydration.
@@ -404,6 +408,19 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
if (!isHydrating) {
return;
}
if (enableFloat) {
if (
fiber.tag === HostComponent &&
isHydratableResource(fiber.type, fiber.pendingProps)
) {
fiber.stateNode = getMatchingResourceInstance(
fiber.type,
fiber.pendingProps,
getRootHostContainer(),
);
return;
}
}
let nextInstance = nextHydratableInstance;
if (!nextInstance) {
if (shouldClientRenderOnMismatch(fiber)) {
@@ -596,6 +613,30 @@ function popHydrationState(fiber: Fiber): boolean {
if (!supportsHydration) {
return false;
}
if (
enableFloat &&
isHydrating &&
isHydratableResource(fiber.type, fiber.memoizedProps)
) {
if (fiber.stateNode === null) {
if (__DEV__) {
const rel = fiber.memoizedProps.rel
? `rel="${fiber.memoizedProps.rel}" `
: '';
const href = fiber.memoizedProps.href
? `href="${fiber.memoizedProps.href}"`
: '';
console.error(
'A matching Hydratable Resource was not found in the DOM for <%s %s%s>.',
fiber.type,
rel,
href,
);
}
throwOnHydrationMismatch(fiber);
}
return true;
}
if (fiber !== hydrationParentFiber) {
// We're deeper than the current hydration context, inside an inserted
// tree.
@@ -186,3 +186,6 @@ export const didNotFindHydratableTextInstance =
export const didNotFindHydratableSuspenseInstance =
$$$hostConfig.didNotFindHydratableSuspenseInstance;
export const errorHydratingContainer = $$$hostConfig.errorHydratingContainer;
export const isHydratableResource = $$$hostConfig.isHydratableResource;
export const getMatchingResourceInstance =
$$$hostConfig.getMatchingResourceInstance;
+40 -10
View File
@@ -117,6 +117,7 @@ import {
warnAboutDefaultPropsOnFunctionComponents,
enableScopeAPI,
enableSuspenseAvoidThisFallbackFizz,
enableFloat,
} from 'shared/ReactFeatureFlags';
import assign from 'shared/assign';
@@ -200,6 +201,8 @@ export opaque type Request = {
clientRenderedBoundaries: Array<SuspenseBoundary>, // Errored or client rendered but not yet flushed.
completedBoundaries: Array<SuspenseBoundary>, // Completed but not yet fully flushed boundaries to show.
partialBoundaries: Array<SuspenseBoundary>, // Partially completed boundaries that can flush its segments early.
+preamble: ?Array<Chunk | PrecomputedChunk>, // Chunks that need to be emitted before any segment chunks.
+postamble: ?Array<Chunk | PrecomputedChunk>, // Chunks that need to be emitted after segments, waiting for all pending root tasks to finish
// onError is called when an error happens anywhere in the tree. It might recover.
// The return string is used in production primarily to avoid leaking internals, secondarily to save bytes.
// Returning null/undefined will cause a defualt error message in production
@@ -272,6 +275,8 @@ export function createRequest(
clientRenderedBoundaries: [],
completedBoundaries: [],
partialBoundaries: [],
preamble: enableFloat ? [] : null,
postamble: enableFloat ? [] : null,
onError: onError === undefined ? defaultErrorHandler : onError,
onAllReady: onAllReady === undefined ? noop : onAllReady,
onShellReady: onShellReady === undefined ? noop : onShellReady,
@@ -632,6 +637,7 @@ function renderHostElement(
const segment = task.blockedSegment;
const children = pushStartInstance(
segment.chunks,
request.preamble,
type,
props,
request.responseState,
@@ -647,7 +653,7 @@ function renderHostElement(
// We expect that errors will fatal the whole task and that we don't need
// the correct context. Therefore this is not in a finally.
segment.formatContext = prevContext;
pushEndInstance(segment.chunks, type, props);
pushEndInstance(segment.chunks, request.postamble, type, props);
segment.lastPushedText = false;
popComponentStackInDEV(task);
}
@@ -2063,20 +2069,33 @@ function flushCompletedQueues(
// TODO: Emit preloading.
// TODO: It's kind of unfortunate to keep checking this array after we've already
// emitted the root.
let i;
const completedRootSegment = request.completedRootSegment;
if (completedRootSegment !== null && request.pendingRootTasks === 0) {
flushSegment(request, destination, completedRootSegment);
request.completedRootSegment = null;
writeCompletedRoot(destination, request.responseState);
if (completedRootSegment !== null) {
if (request.pendingRootTasks === 0) {
if (enableFloat) {
const preamble: Array<
Chunk | PrecomputedChunk,
> = (request.preamble: any);
for (i = 0; i < preamble.length; i++) {
// we expect the preamble to be tiny and will ignore backpressure
writeChunk(destination, preamble[i]);
}
}
flushSegment(request, destination, completedRootSegment);
request.completedRootSegment = null;
writeCompletedRoot(destination, request.responseState);
} else {
// We haven't flushed the root yet so we don't need to check boundaries further down
return;
}
}
// We emit client rendering instructions for already emitted boundaries first.
// This is so that we can signal to the client to start client rendering them as
// soon as possible.
const clientRenderedBoundaries = request.clientRenderedBoundaries;
let i;
for (i = 0; i < clientRenderedBoundaries.length; i++) {
const boundary = clientRenderedBoundaries[i];
if (!flushClientRenderedBoundary(request, destination, boundary)) {
@@ -2139,8 +2158,6 @@ function flushCompletedQueues(
}
largeBoundaries.splice(0, i);
} finally {
completeWriting(destination);
flushBuffered(destination);
if (
request.allPendingTasks === 0 &&
request.pingedTasks.length === 0 &&
@@ -2149,6 +2166,16 @@ function flushCompletedQueues(
// We don't need to check any partially completed segments because
// either they have pending task or they're complete.
) {
if (enableFloat) {
const postamble: Array<
Chunk | PrecomputedChunk,
> = (request.postamble: any);
for (let i = 0; i < postamble.length; i++) {
writeChunk(destination, postamble[i]);
}
}
completeWriting(destination);
flushBuffered(destination);
if (__DEV__) {
if (request.abortableTasks.size !== 0) {
console.error(
@@ -2158,6 +2185,9 @@ function flushCompletedQueues(
}
// We're done.
close(destination);
} else {
completeWriting(destination);
flushBuffered(destination);
}
}
}
+2
View File
@@ -116,6 +116,8 @@ export const enableCPUSuspense = __EXPERIMENTAL__;
// aggressiveness.
export const deletedTreeCleanUpLevel = 3;
export const enableFloat = __EXPERIMENTAL__;
// -----------------------------------------------------------------------------
// Chopping Block
//
@@ -81,6 +81,7 @@ export const enableUseMutableSource = true;
export const enableTransitionTracing = false;
export const enableSymbolFallbackForWWW = false;
export const enableFloat = false;
// Flow magic to verify the exports of this file match the original version.
// eslint-disable-next-line no-unused-vars
type Check<_X, Y: _X, X: Y = _X> = null;
@@ -70,6 +70,7 @@ export const enableUseMutableSource = false;
export const enableTransitionTracing = false;
export const enableSymbolFallbackForWWW = false;
export const enableFloat = false;
// Flow magic to verify the exports of this file match the original version.
// eslint-disable-next-line no-unused-vars
type Check<_X, Y: _X, X: Y = _X> = null;
@@ -70,6 +70,7 @@ export const enableUseMutableSource = false;
export const enableTransitionTracing = false;
export const enableSymbolFallbackForWWW = false;
export const enableFloat = false;
// Flow magic to verify the exports of this file match the original version.
// eslint-disable-next-line no-unused-vars
type Check<_X, Y: _X, X: Y = _X> = null;
@@ -68,6 +68,7 @@ export const enableUseMutableSource = false;
export const enableTransitionTracing = false;
export const enableSymbolFallbackForWWW = false;
export const enableFloat = false;
// Flow magic to verify the exports of this file match the original version.
// eslint-disable-next-line no-unused-vars
type Check<_X, Y: _X, X: Y = _X> = null;
@@ -72,6 +72,7 @@ export const enableUseMutableSource = true;
export const enableTransitionTracing = false;
export const enableSymbolFallbackForWWW = false;
export const enableFloat = false;
// Flow magic to verify the exports of this file match the original version.
// eslint-disable-next-line no-unused-vars
type Check<_X, Y: _X, X: Y = _X> = null;
@@ -70,6 +70,7 @@ export const enableUseMutableSource = false;
export const enableTransitionTracing = false;
export const enableSymbolFallbackForWWW = false;
export const enableFloat = false;
// Flow magic to verify the exports of this file match the original version.
// eslint-disable-next-line no-unused-vars
type Check<_X, Y: _X, X: Y = _X> = null;
@@ -71,6 +71,7 @@ export const enableUseMutableSource = true;
export const enableTransitionTracing = false;
export const enableSymbolFallbackForWWW = false;
export const enableFloat = false;
// Flow magic to verify the exports of this file match the original version.
// eslint-disable-next-line no-unused-vars
type Check<_X, Y: _X, X: Y = _X> = null;
@@ -60,5 +60,6 @@ export const disableNativeComponentFrames = false;
export const createRootStrictEffectsByDefault = false;
export const enableStrictEffects = false;
export const allowConcurrentByDefault = true;
export const enableFloat = false;
// You probably *don't* want to add more hardcoded ones.
// Instead, try to add them above with the __VARIANT__ value.
@@ -52,6 +52,7 @@ export const enableUpdaterTracking = __PROFILE__;
export const enableSuspenseAvoidThisFallback = true;
export const enableSuspenseAvoidThisFallbackFizz = false;
export const enableCPUSuspense = true;
export const enableFloat = false;
// Logs additional User Timing API marks for use with an experimental profiling tool.
export const enableSchedulingProfiler =
+3 -1
View File
@@ -420,5 +420,7 @@
"432": "The render was aborted by the server without a reason.",
"433": "useId can only be used while React is rendering",
"434": "`dangerouslySetInnerHTML` does not make sense on <title>.",
"435": "Unexpected Suspense handler tag (%s). This is a bug in React."
"435": "Unexpected Suspense handler tag (%s). This is a bug in React.",
"436": "Stylesheet resources need a unique representation in the DOM while hydrating and more than one matching DOM Node was found. To fix, ensure you are only rendering one stylesheet link with an href attribute of \"%s\".",
"437": "the \"precedence\" prop for links to stylehseets expects to receive a string but received something of type \"%s\" instead."
}