diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 60a3a3938df3e..10d3237bf38d0 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -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( + <> + a title + + + a body + + , + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + + + a title + + a body + , + ); + + // Hydrate the same thing on the client. We expect this to still fail because is not a Resource + // and is unmatched on hydration + const errors = []; + ReactDOMClient.hydrateRoot( + document, + <> + <title data-baz="baz">a title + + + a body + + , + { + 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 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 diff --git a/packages/react-dom/src/__tests__/ReactDOMRoot-test.js b/packages/react-dom/src/__tests__/ReactDOMRoot-test.js index 9d6a38188376d..0245cebd0a9b8 100644 --- a/packages/react-dom/src/__tests__/ReactDOMRoot-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMRoot-test.js @@ -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>); diff --git a/packages/react-dom/src/client/ReactDOMComponent.js b/packages/react-dom/src/client/ReactDOMComponent.js index 93c1534c235b4..8b48bae61a497 100644 --- a/packages/react-dom/src/client/ReactDOMComponent.js +++ b/packages/react-dom/src/client/ReactDOMComponent.js @@ -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 || diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 12a14df0f1c14..52646306767ce 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -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 { diff --git a/packages/react-dom/src/client/ReactDOMRoot.js b/packages/react-dom/src/client/ReactDOMRoot.js index c522d91f21ff7..9fbed21bd1767 100644 --- a/packages/react-dom/src/client/ReactDOMRoot.js +++ b/packages/react-dom/src/client/ReactDOMRoot.js @@ -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) { diff --git a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js index 36c9469d60818..e9601b38a79a8 100644 --- a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js @@ -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); } diff --git a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js index 3c2c23c911faf..422d0ba3aed26 100644 --- a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js +++ b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js @@ -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 { diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index 14003b8291b37..e70de39ab3868 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -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 { diff --git a/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js b/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js index f61c0eb7789e7..9eba0aad9dc21 100644 --- a/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js +++ b/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js @@ -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; diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js index fd3f9b18a4921..23010daad4970 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js @@ -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. diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js index 9ac9e4dc666b2..28371e823e5c8 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js @@ -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. diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index 8afc9a3aa2cb9..9bb5d6e271c49 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -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; diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index f2974e0507e1d..84db02300fb82 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -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); } } } diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 33d233f92e4ef..98c9b53dcab2f 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -116,6 +116,8 @@ export const enableCPUSuspense = __EXPERIMENTAL__; // aggressiveness. export const deletedTreeCleanUpLevel = 3; +export const enableFloat = __EXPERIMENTAL__; + // ----------------------------------------------------------------------------- // Chopping Block // diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 0e07c9f67d994..3b54f76f1a525 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -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; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 933d914ce5a62..fc125d4110737 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -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; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index b218c53470bda..cbdd24ef5a901 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -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; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js index b76a1b8506d13..ec4e9deddc726 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js @@ -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; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 63ba329f01a90..6fd3a292861f0 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -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; diff --git a/packages/shared/forks/ReactFeatureFlags.testing.js b/packages/shared/forks/ReactFeatureFlags.testing.js index 9e5a9107ab2b1..0faa78126ee5f 100644 --- a/packages/shared/forks/ReactFeatureFlags.testing.js +++ b/packages/shared/forks/ReactFeatureFlags.testing.js @@ -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; diff --git a/packages/shared/forks/ReactFeatureFlags.testing.www.js b/packages/shared/forks/ReactFeatureFlags.testing.www.js index a4c3e1ba32678..b987da6c350b8 100644 --- a/packages/shared/forks/ReactFeatureFlags.testing.www.js +++ b/packages/shared/forks/ReactFeatureFlags.testing.www.js @@ -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; diff --git a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js index 7a89e41ad54f2..dd6dcff47b307 100644 --- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js @@ -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. diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 50d078d3f0fd9..4876ac1ff30ec 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -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 = diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 010afa06e70f3..d5f273fe1e4d5 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -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." }