From ec5e9c2a75749b0a470b7148738cb85bbb035958 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Tue, 25 Apr 2023 15:10:34 -0700 Subject: [PATCH] Fix double preload (#26729) I found a couple scenarios where preloads were issued too aggressively 1. During SSR, if you render a new stylesheet after the preamble flushed it will flush a preload even if the resource was already preloaded 2. During Client render, if you call `ReactDOM.preload()` it will only check if a preload exists in the Document before inserting a new one. It should check for an underlying resource such as a stylesheet link or script if the preload is for a recognized asset type --- .../src/client/ReactFiberConfigDOM.js | 23 ++- .../src/server/ReactFizzConfigDOM.js | 22 ++- .../src/__tests__/ReactDOMFloat-test.js | 160 ++++++++++++++++++ 3 files changed, 194 insertions(+), 11 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 455074f626..6886b01562 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -2138,8 +2138,12 @@ function preload(href: string, options: PreloadOptions) { const as = options.as; const limitedEscapedHref = escapeSelectorAttributeValueInsideDoubleQuotes(href); - const preloadKey = `link[rel="preload"][as="${as}"][href="${limitedEscapedHref}"]`; - let key = preloadKey; + const preloadSelector = `link[rel="preload"][as="${as}"][href="${limitedEscapedHref}"]`; + + // Some preloads are keyed under their selector. This happens when the preload is for + // an arbitrary type. Other preloads are keyed under the resource key they represent a preload for. + // Here we figure out which key to use to determine if we have a preload already. + let key = preloadSelector; switch (as) { case 'style': key = getStyleKey(href); @@ -2152,7 +2156,20 @@ function preload(href: string, options: PreloadOptions) { const preloadProps = preloadPropsFromPreloadOptions(href, as, options); preloadPropsMap.set(key, preloadProps); - if (null === ownerDocument.querySelector(preloadKey)) { + if (null === ownerDocument.querySelector(preloadSelector)) { + if ( + as === 'style' && + ownerDocument.querySelector(getStylesheetSelectorFromKey(key)) + ) { + // We already have a stylesheet for this key. We don't need to preload it. + return; + } else if ( + as === 'script' && + ownerDocument.querySelector(getScriptSelectorFromKey(key)) + ) { + // We already have a stylesheet for this key. We don't need to preload it. + return; + } const instance = ownerDocument.createElement('link'); setInitialProperties(instance, 'link', preloadProps); markNodeAsHoistable(instance); diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 279eef1576..10a49b447d 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -1900,6 +1900,7 @@ function pushLink( if (!resource) { const resourceProps = stylesheetPropsFromRawProps(props); const preloadResource = resources.preloadsMap.get(key); + let state = NoState; if (preloadResource) { // If we already had a preload we don't want that resource to flush directly. // We let the newly created resource govern flushing. @@ -1908,11 +1909,14 @@ function pushLink( resourceProps, preloadResource.props, ); + if (preloadResource.state & Flushed) { + state = PreloadFlushed; + } } resource = { type: 'stylesheet', chunks: ([]: Array), - state: NoState, + state, props: resourceProps, }; resources.stylesMap.set(key, resource); @@ -4004,12 +4008,9 @@ function flushAllStylesInPreamble( } function preloadLateStyle(this: Destination, resource: StyleResource) { - if (__DEV__) { - if (resource.state & PreloadFlushed) { - console.error( - 'React encountered a Stylesheet Resource that already flushed a Preload when it was not expected to. This is a bug in React.', - ); - } + if (resource.state & PreloadFlushed) { + // This resource has already had a preload flushed + return; } if (resource.type === 'style') { @@ -5209,10 +5210,15 @@ function preinit(href: string, options: PreinitOptions): void { } } if (!resource) { + let state = NoState; + const preloadResource = resources.preloadsMap.get(key); + if (preloadResource && preloadResource.state & Flushed) { + state = PreloadFlushed; + } resource = { type: 'stylesheet', chunks: ([]: Array), - state: NoState, + state, props: stylesheetPropsFromPreinitOptions(href, precedence, options), }; resources.stylesMap.set(key, resource); diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 81ed890e04..48603d89d2 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -3391,6 +3391,166 @@ body { ); }); + it('will not flush a preload for a new rendered Stylesheet Resource if one was already flushed', async () => { + function Component() { + ReactDOM.preload('foo', {as: 'style'}); + return ( +
+ + + + hello + + +
+ ); + } + await act(() => { + renderToPipeableStream( + + + + + , + ).pipe(writable); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + +
loading...
+ + , + ); + await act(() => { + resolveText('blocked'); + }); + await act(loadStylesheets); + assertLog(['load stylesheet: foo']); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + +
hello
+ + , + ); + }); + + it('will not flush a preload for a new preinitialized Stylesheet Resource if one was already flushed', async () => { + function Component() { + ReactDOM.preload('foo', {as: 'style'}); + return ( +
+ + + + hello + + +
+ ); + } + + function Preinit() { + ReactDOM.preinit('foo', {as: 'style'}); + } + await act(() => { + renderToPipeableStream( + + + + + , + ).pipe(writable); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + +
loading...
+ + , + ); + await act(() => { + resolveText('blocked'); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + +
hello
+ + , + ); + }); + + it('will not insert a preload if the underlying resource already exists in the Document', async () => { + await act(() => { + renderToPipeableStream( + + + +