From 6396b664118442f3c2eae7bf13732fcb27bda98f Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 9 Feb 2023 22:59:29 -0800 Subject: [PATCH] Model Float on Hoistables semantics (#26106) ## Hoistables In the original implementation of Float, all hoisted elements were treated like Resources. They had deduplication semantics and hydrated based on a key. This made certain kinds of hoists very challenging such as sequences of meta tags for `og:image:...` metadata. The reason is each tag along is not dedupable based on only it's intrinsic properties. two identical tags may need to be included and hoisted together with preceding meta tags that describe a semantic object with a linear set of html nodes. It was clear that the concept of Browser Resources (stylesheets / scripts / preloads) did not extend universally to all hositable tags (title, meta, other links, etc...) Additionally while Resources benefit from deduping they suffer an inability to update because while we may have multiple rendered elements that refer to a single Resource it isn't unambiguous which element owns the props on the underlying resource. We could try merging props, but that is still really hard to reason about for authors. Instead we restrict Resource semantics to freezing the props at the time the Resource is first constructed and warn if you attempt to render the same Resource with different props via another rendered element or by updating an existing element for that Resource. This lack of updating restriction is however way more extreme than necessary for instances that get hoisted but otherwise do not dedupe; where there is a well defined DOM instance for each rendered element. We should be able to update props on these instances. Hoistable is a generalization of what Float tries to model for hoisting. Instead of assuming every hoistable element is a Resource we now have two distinct categories, hoistable elements and hoistable resources. As one might guess the former has semantics that match regular Host Components except the placement of the node is usually in the . The latter continues to behave how the original implementation of HostResource behaved with the first iteration of Float ### Hoistable Element On the server hoistable elements render just like regular tags except the output is stored in special queues that can be emitted in the stream earlier than they otherwise would be if rendered in place. This also allow for instance the ability to render a hoistable before even rendering the tag because the queues for hoistable elements won't flush until after we have flushed the preamble (``). On the client, hoistable elements largely operate like HostComponents. The most notable difference is in the hydration strategy. If we are hydrating and encounter a hoistable element we will look for all tags in the document that could potentially be a match and we check whether the attributes match the props for this particular instance. We also do this in the commit phase rather than the render phase. The reason hydration can be done for HostComponents in render is the instance will be removed from the document if hydration fails so mutating it in render is safe. For hoistables the nodes are not in a hydration boundary (Root or SuspenseBoundary at time of writing) and thus if hydration fails and we may have an instance marked as bound to some Fiber when that Fiber never commits. Moving the hydration matching to commit ensures we will always succeed in pairing the hoisted DOM instance with a Fiber that has committed. ### Hoistable Resource On the server and client the semantics of Resources are largely the same they just don't apply to title, meta, and most link tags anymore. Resources hoist and dedupe via an `href` key and are ref counted. In a future update we will add a garbage collector so we can clean up Resources that no longer have any references ## `` as a Resource analagous to `` It may seem odd at first to require an href to get Resource semantics for a style tag. The rationale is that these are for inlining of actual external stylesheets as an optimization and for URI like scoping of inline styles for css-in-js libraries. The href indicates that the key space for `'); -export function writeInitialResources( +function flushResourceInPreamble(this: Destination, resource: T) { + if ((resource.state & (Flushed | Blocked)) === NoState) { + const chunks = resource.chunks; + for (let i = 0; i < chunks.length; i++) { + writeChunk(this, chunks[i]); + } + resource.state |= FlushedInPreamble; + } +} + +function flushResourceLate(this: Destination, resource: T) { + if ((resource.state & Flushed) === NoState) { + const chunks = resource.chunks; + for (let i = 0; i < chunks.length; i++) { + writeChunk(this, chunks[i]); + } + resource.state |= FlushedLate; + } +} + +let didFlush = false; + +function flushUnblockedStyle( + this: Destination, + resource: StyleResource, + key: mixed, + set: Set, +) { + const chunks = resource.chunks; + if (resource.state & Flushed) { + // In theory this should never happen because we clear from the + // Set on flush but to ensure correct semantics we don't emit + // anything if we are in this state. + set.delete(resource); + } else if (resource.state & Blocked) { + // We can't flush but we can preload. We will do this in a second pass + } else { + didFlush = true; + // We can emit this style or stylesheet as is. + + if (resource.type === 'stylesheet') { + // We still need to encode stylesheet chunks + // because unlike most Hoistables and Resources we do not eagerly encode + // them during render. This is because if we flush late we have to send a + // different encoding and we don't want to encode multiple times + pushLinkImpl(chunks, resource.props); + } + for (let i = 0; i < chunks.length; i++) { + writeChunk(this, chunks[i]); + } + resource.state |= FlushedInPreamble; + set.delete(resource); + } +} + +function flushUnblockedStyles( + this: Destination, + set: Set, + precedence: string, +) { + didFlush = false; + set.forEach(flushUnblockedStyle, this); + if (!didFlush) { + // if we did not flush anything for this precedence slot we emit + // an empty , ).toErrorDev([ - 'Cannot render + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + , + ).pipe(writable); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + , + ); + + await act(() => { + resolveText('block'); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + + + + + + + + + + , + ); + + await act(() => { + resolveText('block2'); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + + + + + + + + + + + + + + + + + , + ); + + await act(() => { + resolveText('block again'); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + + + + + + + + + + + + + + + + + , + ); + + ReactDOMClient.hydrateRoot( + document, + + + + + + + + + + , + ); + expect(Scheduler).toFlushWithoutYielding(); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + , + ); + }); + + // @gate enableFloat + it('client renders a boundary if a style Resource dependency fails to load', async () => { + function App() { + return ( + + + + + + + + Hello + + + + + ); + } + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); + }); + + await act(() => { + resolveText('unblock'); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + loading... + + + + , + ); + + await act(() => { + const barLink = document.querySelector( + 'link[rel="stylesheet"][href="bar"]', + ); + const event = document.createEvent('Events'); + event.initEvent('error', true, true); + barLink.dispatchEvent(event); + }); + + const boundaryTemplateInstance = document.getElementById('B:0'); + const suspenseInstance = boundaryTemplateInstance.previousSibling; + + expect(suspenseInstance.data).toEqual('$!'); + expect(boundaryTemplateInstance.dataset.dgst).toBe( + 'Resource failed to load', + ); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + loading... + + + + , + ); + + const errors = []; + ReactDOMClient.hydrateRoot(document, , { + onRecoverableError(err, errInfo) { + errors.push(err.message); + errors.push(err.digest); + }, + }); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + Hello + + , + ); + expect(errors).toEqual([ + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', + 'Resource failed to load', + ]); + }); + + // @gate enableFloat + it('treats stylesheet links with a precedence as a resource', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream( + + + + + Hello + + , + ); + pipe(writable); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + Hello + , + ); + + ReactDOMClient.hydrateRoot( + document, + + + Hello + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + + + Hello + , + ); + }); + + // @gate enableFloat + it('inserts text separators following text when followed by an element that is converted to a resource and thus removed from the html inline', async () => { + // If you render many of these as siblings the values get emitted as a single text with no separator sometimes + // because the link gets elided as a resource + function AsyncTextWithResource({text, href, precedence}) { + const value = readText(text); + return ( + <> + {value} + + + ); + } + + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream( + + + + + + + + , + ); + pipe(writable); + resolveText('foo'); + resolveText('bar'); + resolveText('baz'); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + {'foo'} + {'bar'} + {'baz'} + + , + ); + }); + + // @gate enableFloat + it('hoists late stylesheets the correct precedence', async () => { + function PresetPrecedence() { + ReactDOM.preinit('preset', {as: 'style', precedence: 'preset'}); + } + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream( + + + + + +
+ +
foo
+ + +
bar
+ +
+
+
+
+ + +
bar
+ +
+ +
baz
+ +
+ +
qux
+ +
+
+
+
+ + + +
bar
+ +
+ +
baz
+ +
+ +
qux
+ +
+
+
, ); + pipe(writable); }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + +
loading foo bar...
+
loading bar baz qux...
+
loading bar baz qux...
+ + , + ); + + await act(() => { + resolveText('foo'); + resolveText('bar'); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + +
loading foo bar...
+
loading bar baz qux...
+
loading bar baz qux...
+ + + , + ); + + await act(() => { + const link = document.querySelector('link[rel="stylesheet"][href="foo"]'); + const event = document.createEvent('Events'); + event.initEvent('load', true, true); + link.dispatchEvent(event); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + +
loading foo bar...
+
loading bar baz qux...
+
loading bar baz qux...
+ + + , + ); + + await act(() => { + const link = document.querySelector('link[rel="stylesheet"][href="bar"]'); + const event = document.createEvent('Events'); + event.initEvent('load', true, true); + link.dispatchEvent(event); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + +
+
foo
+
bar
+
+
loading bar baz qux...
+
loading bar baz qux...
+ + + , + ); + + await act(() => { + resolveText('baz'); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + +
+
foo
+
bar
+
+
loading bar baz qux...
+
loading bar baz qux...
+ + + + , + ); + + await act(() => { + resolveText('qux'); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + + + +
+
foo
+
bar
+
+
loading bar baz qux...
+
loading bar baz qux...
+ + + + + , + ); + + await act(() => { + const bazlink = document.querySelector( + 'link[rel="stylesheet"][href="baz"]', + ); + const quxlink = document.querySelector( + 'link[rel="stylesheet"][href="qux"]', + ); + const presetLink = document.querySelector( + 'link[rel="stylesheet"][href="preset"]', + ); + const event = document.createEvent('Events'); + event.initEvent('load', true, true); + bazlink.dispatchEvent(event); + quxlink.dispatchEvent(event); + presetLink.dispatchEvent(event); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + + + +
+
foo
+
bar
+
+
+
bar
+
baz
+
qux
+
+
loading bar baz qux...
+ + + + + , + ); + + await act(() => { + resolveText('unblock'); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + + + +
+
foo
+
bar
+
+
+
bar
+
baz
+
qux
+
+
+
bar
+
baz
+
qux
+
+ + + + + , + ); }); - describe('ReactDOM.preload', () => { - // @gate enableFloat - it('inserts a preload resource into the stream when called during server rendering', async () => { - function Component() { - ReactDOM.preload('foo', {as: 'style'}); - return 'foo'; + // @gate enableFloat + it('normalizes stylesheet resource precedence for all boundaries inlined as part of the shell flush', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream( + + + +
+ outer + + + + + +
+ middle + + + + + +
+ inner + + + + + +
+
+
+
+ +
middle
+ + + + +
+
+ + , + ); + pipe(writable); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + + + + + + + + + + + + + + + + + +
+ outer +
+ middle
inner
+
+
middle
+
+ + , + ); + }); + + // @gate enableFloat + it('stylesheet resources are inserted according to precedence order on the client', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream( + + + +
+ + + Hello +
+ + , + ); + pipe(writable); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + +
Hello
+ + , + ); + + const root = ReactDOMClient.hydrateRoot( + document, + + + +
+ + + Hello +
+ + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + +
Hello
+ + , + ); + + root.render( + + + +
Hello
+ + + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + +
Hello
+ + , + ); + }); + + // @gate enableFloat + it('inserts preloads in render phase eagerly', async () => { + function Throw() { + throw new Error('Uh oh!'); + } + class ErrorBoundary extends React.Component { + state = {hasError: false, error: null}; + static getDerivedStateFromError(error) { + return { + hasError: true, + error, + }; } + render() { + if (this.state.hasError) { + return this.state.error.message; + } + return this.props.children; + } + } + + const root = ReactDOMClient.createRoot(container); + root.render( + + +
foo
+ +
, + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + + + +
Uh oh!
+ + , + ); + }); + + // @gate enableFloat + it('will include child boundary stylesheet resources in the boundary reveal instruction', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream( + + + +
+ + +
foo
+ + + +
bar
+ + + +
baz
+ +
+
+
+
+
+
+
+ + , + ); + pipe(writable); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + +
loading foo...
+ + , + ); + + await act(() => { + resolveText('bar'); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + +
loading foo...
+ + , + ); + + await act(() => { + resolveText('baz'); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + +
loading foo...
+ + , + ); + + await act(() => { + resolveText('foo'); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + +
loading foo...
+ + + + + , + ); + + await act(() => { + const event = document.createEvent('Events'); + event.initEvent('load', true, true); + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).forEach( + el => { + el.dispatchEvent(event); + }, + ); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + +
+
foo
+
bar
+
baz
+
+ + + + + , + ); + }); + + // @gate enableFloat + it('will hoist resources of child boundaries emitted as part of a partial boundary to the parent boundary', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream( + + + +
+ +
+ +
foo
+ + + +
bar
+ + +
+ +
baz
+ +
+
+
+
+
+
+ +
qux
+ +
+
+
+
+ + , + ); + pipe(writable); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + +
loading...
+ + , + ); + + // This will enqueue a stylesheet resource in a deep blocked boundary (loading baz...). + await act(() => { + resolveText('baz'); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + +
loading...
+ + , + ); + + // This will enqueue a stylesheet resource in the intermediate blocked boundary (loading bar...). + await act(() => { + resolveText('bar'); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + +
loading...
+ + , + ); + + // This will complete a segment in the top level boundary that is still blocked on another segment. + // It will flush the completed segment however the inner boundaries should not emit their style dependencies + // because they are not going to be revealed yet. instead their dependencies are hoisted to the blocked + // boundary (top level). + await act(() => { + resolveText('foo'); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + +
loading...
+ + + + + , + ); + + // This resolves the last blocked segment on the top level boundary so we see all dependencies of the + // nested boundaries emitted at this level + await act(() => { + resolveText('qux'); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + +
loading...
+ + + + + + , + ); + + // We load all stylesheets and confirm the content is revealed + await act(() => { + const event = document.createEvent('Events'); + event.initEvent('load', true, true); + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).forEach( + el => { + el.dispatchEvent(event); + }, + ); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + +
+
+
foo
+
bar
+
+
baz
+
+
qux
+
+
+ + + + + + , + ); + }); + + // @gate enableFloat + it('encodes attributes consistently whether resources are flushed in shell or in late boundaries', async () => { + function App() { + return ( + + + +
+ {}} + norsymbols={Symbol('foo')} + /> + + + {}} + norsymbols={Symbol('foo')} + /> + + +
+ + + ); + } + await expect(async () => { await actIntoEmptyDocument(() => { - const {pipe} = renderToPipeableStream( - - - - - - , - ); + const {pipe} = renderToPipeableStream(); pipe(writable); }); expect(getMeaningfulChildren(document)).toEqual( - - - foo - , - ); - }); - - // @gate enableFloat - it('inserts a preload resource into the document during render when called during client rendering', async () => { - function Component() { - ReactDOM.preload('foo', {as: 'style'}); - return 'foo'; - } - const root = ReactDOMClient.createRoot(container); - root.render(); - expect(Scheduler).toFlushWithoutYielding(); - expect(getMeaningfulChildren(document)).toEqual( - - - + -
foo
+
loading...
, ); + }).toErrorDev([ + 'React does not recognize the `nonStandardAttr` prop on a DOM element.' + + ' If you intentionally want it to appear in the DOM as a custom attribute,' + + ' spell it as lowercase `nonstandardattr` instead. If you accidentally passed it from a' + + ' parent component, remove it from the DOM element.', + 'Invalid values for props `shouldnotincludefunctions`, `norsymbols` on tag. Either remove them from' + + ' the element, or pass a string or number value to keep them in the DOM. For' + + ' details, see https://reactjs.org/link/attribute-behavior', + ]); + + // Now we flush the stylesheet with the boundary + await act(() => { + resolveText('unblock'); }); - // @gate enableFloat - it('inserts a preload resource when called in a layout effect', async () => { - function App() { - React.useLayoutEffect(() => { - ReactDOM.preload('foo', {as: 'style'}); - }, []); - return 'foobar'; - } - const root = ReactDOMClient.createRoot(container); - root.render(); - expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + +
loading...
+ + + , + ); + }); - expect(getMeaningfulChildren(document)).toEqual( + // @gate enableFloat + it('boundary stylesheet resource dependencies hoist to a parent boundary when flushed inline', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream( - - - + -
foobar
+
+ + + + + + + + + + + + + + + + + + +
, ); + pipe(writable); }); + expect(getMeaningfulChildren(document)).toEqual( + + + +
loading A...
+ + , + ); - // @gate enableFloat - it('inserts a preload resource when called in a passive effect', async () => { - function App() { - React.useEffect(() => { - ReactDOM.preload('foo', {as: 'style'}); - }, []); - return 'foobar'; - } - const root = ReactDOMClient.createRoot(container); - root.render(); - expect(Scheduler).toFlushWithoutYielding(); - - expect(getMeaningfulChildren(document)).toEqual( - - - - - -
foobar
- - , - ); + await act(() => { + resolveText('unblock'); + resolveText('AAAA'); + resolveText('AA'); }); + expect(getMeaningfulChildren(document)).toEqual( + + + +
loading A...
+ + + + + + , + ); - // @gate enableFloat - it('inserts a preload resource when called in module scope if a root has already been created', async () => { - // The requirement that a root be created has to do with bootstrapping the dispatcher. - // We are intentionally avoiding setting it to the default via import due to cycles and - // we are trying to avoid doing a mutable initailation in module scope. - ReactDOM.preload('foo', {as: 'style'}); - ReactDOMClient.createRoot(container); - ReactDOM.preload('bar', {as: 'style'}); - // We need to use global.document because preload falls back - // to the window.document global when no other documents have been used - // The way the JSDOM runtim is created for these tests the local document - // global does not point to the global.document - expect(getMeaningfulChildren(global.document)).toEqual( + await act(() => { + resolveText('A'); + }); + await act(() => { + document.querySelectorAll('link[rel="stylesheet"]').forEach(l => { + const event = document.createEvent('Events'); + event.initEvent('load', true, true); + l.dispatchEvent(event); + }); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + +
+ {'A'} + {'AA'} + {'loading AAA...'} +
+ + + + + + , + ); + + await act(() => { + resolveText('AAA'); + }); + await act(() => { + document.querySelectorAll('link[rel="stylesheet"]').forEach(l => { + const event = document.createEvent('Events'); + event.initEvent('load', true, true); + l.dispatchEvent(event); + }); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + +
+ {'A'} + {'AA'} + {'AAA'} + {'AAAA'} +
+ + + + + + , + ); + }); + + // @gate enableFloat + it('always enforces crossOrigin "anonymous" for font preloads', async () => { + function App() { + ReactDOM.preload('foo', {as: 'font'}); + ReactDOM.preload('bar', {as: 'font', crossOrigin: 'foo'}); + ReactDOM.preload('baz', {as: 'font', crossOrigin: 'use-credentials'}); + ReactDOM.preload('qux', {as: 'font', crossOrigin: 'anonymous'}); + return ( - - - + - , - ); - }); - - // @gate enableFloat - it('supports script preloads', async () => { - function ServerApp() { - ReactDOM.preload('foo', {as: 'script', integrity: 'foo hash'}); - ReactDOM.preload('bar', { - as: 'script', - crossOrigin: 'use-credentials', - integrity: 'bar hash', - }); - return ( - - - - hi - - foo - - ); - } - function ClientApp() { - ReactDOM.preload('foo', {as: 'script', integrity: 'foo hash'}); - ReactDOM.preload('qux', {as: 'script'}); - return ( - - - hi - - foo - - - ); - } - - await actIntoEmptyDocument(() => { - const {pipe} = renderToPipeableStream(); - pipe(writable); - }); - expect(getMeaningfulChildren(document)).toEqual( - - - - - - hi - - foo - , - ); - - ReactDOMClient.hydrateRoot(document, ); - expect(Scheduler).toFlushWithoutYielding(); - - expect(getMeaningfulChildren(document)).toEqual( - - - - - - hi - - - - foo - , + ); + } + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + , + ); }); - describe('ReactDOM.preinit as style', () => { + describe('ReactDOM.preload(href, { as: ... })', () => { // @gate enableFloat - it('creates a style Resource when called during server rendering before first flush', async () => { - function Component() { - ReactDOM.preinit('foo', {as: 'style'}); - return 'foo'; - } - await actIntoEmptyDocument(() => { - const {pipe} = renderToPipeableStream( + it('creates a preload resource when called', async () => { + function App() { + ReactDOM.preload('foo', {as: 'style'}); + return ( - - - - - , - ); - pipe(writable); - }); - expect(getMeaningfulChildren(document)).toEqual( - - - - - foo - , - ); - }); - - // @gate enableFloat - it('creates a preload Resource when called during server rendering after first flush', async () => { - function BlockedOn({text, children}) { - readText(text); - return children; - } - function Component() { - ReactDOM.preinit('foo', {as: 'style', precedence: 'foo'}); - return 'foo'; - } - await actIntoEmptyDocument(() => { - const {pipe} = renderToPipeableStream( - - - + - , + ); + } + function Component() { + ReactDOM.preload('bar', {as: 'script'}); + return
hello
; + } + + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream(); pipe(writable); }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + loading... + , + ); + await act(() => { - resolveText('unblock'); + resolveText('blocked'); }); expect(getMeaningfulChildren(document)).toEqual( - - - foo + + + +
hello
+ , ); - }); - // @gate enableFloat - it('inserts a style Resource into the document during render when called during client rendering', async () => { - function Component() { - ReactDOM.preinit('foo', {as: 'style', precedence: 'foo'}); - return 'foo'; + function ClientApp() { + ReactDOM.preload('foo', {as: 'style'}); + React.useInsertionEffect(() => ReactDOM.preload('bar', {as: 'script'})); + React.useLayoutEffect(() => ReactDOM.preload('baz', {as: 'font'})); + React.useEffect(() => ReactDOM.preload('qux', {as: 'style'})); + return ( + + + +
hello
+
+ + + ); } - const root = ReactDOMClient.createRoot(container); - root.render(); + ReactDOMClient.hydrateRoot(document, ); expect(Scheduler).toFlushWithoutYielding(); expect(getMeaningfulChildren(document)).toEqual( - + + + -
foo
+
hello
+ , ); }); // @gate enableFloat - it('inserts a preload resource into the document when called in an insertion effect, layout effect, or passive effect', async () => { + it('can seed connection props for stylesheet and script resources', async () => { function App() { - React.useEffect(() => { - ReactDOM.preinit('passive', {as: 'style', precedence: 'default'}); - }, []); - React.useLayoutEffect(() => { - ReactDOM.preinit('layout', {as: 'style', precedence: 'default'}); + ReactDOM.preload('foo', { + as: 'style', + crossOrigin: 'use-credentials', + integrity: 'some hash', }); - React.useInsertionEffect(() => { - ReactDOM.preinit('insertion', {as: 'style', precedence: 'default'}); - }); - return 'foobar'; + return ( + + +
hello
+ + + + ); } - const root = ReactDOMClient.createRoot(container); - root.render(); - expect(Scheduler).toFlushWithoutYielding(); + + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); + }); expect(getMeaningfulChildren(document)).toEqual( - - - + -
foobar
+
hello
, ); }); // @gate enableFloat - it('inserts a preload resource when called in module scope', async () => { - // The requirement that a root be created has to do with bootstrapping the dispatcher. - // We are intentionally avoiding setting it to the default via import due to cycles and - // we are trying to avoid doing a mutable initailation in module scope. - ReactDOM.preinit('foo', {as: 'style'}); - ReactDOMClient.hydrateRoot(container, null); - ReactDOM.preinit('bar', {as: 'style'}); - // We need to use global.document because preload falls back - // to the window.document global when no other documents have been used - // The way the JSDOM runtim is created for these tests the local document - // global does not point to the global.document - expect(getMeaningfulChildren(global.document)).toEqual( - - - - - - , - ); + it('warns if you do not pass in a valid href argument or options argument', async () => { + function App() { + ReactDOM.preload(); + ReactDOM.preload(''); + ReactDOM.preload('foo', null); + ReactDOM.preload('foo', {}); + ReactDOM.preload('foo', {as: 'foo'}); + return
foo
; + } + + await expect(async () => { + await act(() => { + renderToPipeableStream().pipe(writable); + }); + }).toErrorDev([ + 'ReactDOM.preload(): Expected the `href` argument (first) to be a non-empty string but encountered `undefined` instead.', + 'ReactDOM.preload(): Expected the `href` argument (first) to be a non-empty string but encountered an empty string instead.', + 'ReactDOM.preload(): Expected the `options` argument (second) to be an object with an `as` property describing the type of resource to be preloaded but encountered `null` instead.', + 'ReactDOM.preload(): Expected the `as` property in the `options` argument (second) to contain a string value describing the type of resource to be preloaded but encountered `undefined` instead. Values that are valid in for the `as` attribute of a `` tag are valid here.', + ]); + }); + + // @gate enableFloat + it('warns if you pass incompatible options to two `ReactDOM.preload(...)` with the same href', async () => { + function Component() { + ReactDOM.preload('foo', { + as: 'font', + crossOrigin: 'use-credentials', + }); + ReactDOM.preload('foo', { + as: 'font', + integrity: 'some hash', + crossOrigin: 'anonymous', + }); + ReactDOM.preload('foo', { + as: 'font', + extra: 'ignored', + }); + } + + await expect(async () => { + await actIntoEmptyDocument(() => { + renderToPipeableStream( + + + + + , + ); + }); + }).toErrorDev([ + 'Warning: ReactDOM.preload(): The options provided conflict with another call to `ReactDOM.preload("foo", { as: "font", ...})`. React will always use the options it first encounters when preloading a resource for a given `href` and `as` type, and any later options will be ignored if different. Try updating all calls to `ReactDOM.preload()` with the same `href` and `as` type to use the same options, or eliminate one of the calls.\n "integrity" option value: "some hash", missing from original options\n "crossOrigin" option value: "anonymous", original option value: "use-credentials"', + 'Warning: ReactDOM.preload(): The options provided conflict with another call to `ReactDOM.preload("foo", { as: "font", ...})`. React will always use the options it first encounters when preloading a resource for a given `href` and `as` type, and any later options will be ignored if different. Try updating all calls to `ReactDOM.preload()` with the same `href` and `as` type to use the same options, or eliminate one of the calls.\n "crossOrigin" missing from options, original option value: "use-credentials"', + ]); + }); + + // @gate enableFloat + it('warns if you pass incompatible options to two `ReactDOM.preload(...)` when an implicit preload already exists with the same href', async () => { + function Component() { + ReactDOM.preload('foo', { + as: 'style', + crossOrigin: 'use-credentials', + }); + } + + await expect(async () => { + await actIntoEmptyDocument(() => { + renderToPipeableStream( + + + + + + , + ); + }); + }).toErrorDev([ + 'ReactDOM.preload(): For `href` "foo", The options provided conflict with props on a matching element. When the preload options disagree with the underlying resource it usually means the browser will not be able to use the preload when the resource is fetched, negating any benefit the preload would provide. React will preload the resource using props derived from the resource instead and ignore the options provided to the `ReactDOM.preload()` call. In general, preloading is useful when you expect to render a resource soon but have not yet done so. In this case since the underlying resource was already rendered the preload call may be extraneous. Try removing the call, otherwise try adjusting both the props on the and the options passed to `ReactDOM.preload()` to agree.\n "integrity" missing from options, underlying prop value: "some hash"\n "media" missing from options, underlying prop value: "print"\n "crossOrigin" option value: "use-credentials", missing from underlying props', + ]); }); }); - describe('ReactDOM.preinit as script', () => { + describe('ReactDOM.preinit(href, { as: ... })', () => { // @gate enableFloat - it('can preinit a script', async () => { - function App({srcs}) { - srcs.forEach(src => ReactDOM.preinit(src, {as: 'script'})); + it('creates a stylesheet resource when ReactDOM.preinit(..., {as: "style" }) is called', async () => { + function App() { + ReactDOM.preinit('foo', {as: 'style'}); + return ( + + + + + + + + + + ); + } + + function Component() { + ReactDOM.preinit('bar', {as: 'style'}); + return
hello
; + } + + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + loading... + , + ); + + await act(() => { + resolveText('bar'); + }); + // The reason we do not see the "bar" stylesheet here is that ReactDOM.preinit is not about + // encoding a resource dependency but is a hint that a resource will be used in the near future. + // If we call preinit on the server after the shell has flushed the best we can do is emit a preload + // because any flushing suspense boundaries are not actually dependent on that resource and we don't + // want to delay reveal based on when that resource loads. + expect(getMeaningfulChildren(document)).toEqual( + + + + + +
hello
+ + + , + ); + + function ClientApp() { + ReactDOM.preinit('bar', {as: 'style'}); + return ( + + + +
hello
+
+ + + ); + } + + ReactDOMClient.hydrateRoot(document, ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + +
hello
+ + + , + ); + }); + + // @gate enableFloat + it('creates a preload resource when ReactDOM.preinit(..., {as: "style" }) is called outside of render on the client', async () => { + function App() { + React.useEffect(() => { + ReactDOM.preinit('foo', {as: 'style'}); + }, []); return ( - - title - foo ); } - await actIntoEmptyDocument(() => { - const {pipe} = renderToPipeableStream( - , - ); - pipe(writable); - }); - expect(getMeaningfulChildren(document)).toEqual( - - -