From e85161aa4886c5d99c1f406083120ee4aaeb0b88 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Mon, 20 Mar 2023 14:35:20 -0700 Subject: [PATCH] Implement suspensey css for float Implements waitForCommitToBeReady for resources. currently it is only opted into when a special prop is passed. This will be removed in the next commit when I update all the tests that now require different mechanics to simulate resource loading. The general approach is to track how many things we are waiting on and when we hit zero proceed with the commit. For Float CSS in particular we wait for all stylesheet preloads before inserting any uninserted stylesheets. When all the stylesheets have loaded we continue the commit as usual. --- .../src/client/ReactDOMHostConfig.js | 353 ++++++++++++++++-- ...actDOMFizzInstructionSetExternalRuntime.js | 16 +- ...tDOMFizzInstructionSetInlineCodeStrings.js | 2 +- .../ReactDOMFizzInstructionSetInlineSource.js | 16 +- .../ReactDOMFizzInstructionSetShared.js | 2 - .../src/__tests__/ReactDOMFloat-test.js | 59 +++ .../src/createReactNoop.js | 19 + .../src/ReactFiberCommitWork.js | 74 +++- .../src/ReactFiberCompleteWork.js | 13 +- .../ReactFiberHostConfigWithNoResources.js | 3 + .../src/ReactFiberWorkLoop.js | 4 +- .../src/forks/ReactFiberHostConfig.custom.js | 3 + scripts/error-codes/codes.json | 3 +- 13 files changed, 486 insertions(+), 81 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js index 9fd39c0bb1412..d9a673becd58d 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js @@ -1620,23 +1620,6 @@ export function requestPostPaintCallback(callback: (time: number) => void) { }); } -export function maySuspendCommit(type: Type, props: Props): boolean { - return false; -} - -export function preloadInstance(type: Type, props: Props): boolean { - // Return true to indicate it's already loaded - return true; -} - -export function startSuspendingCommit(): void {} - -export function suspendInstance(type: Type, props: Props): void {} - -export function waitForCommitToBeReady(): null { - return null; -} - // ------------------- // Singletons // ------------------- @@ -1791,18 +1774,34 @@ export const supportsResources = true; type ResourceType = 'style' | 'font' | 'script'; type HoistableTagType = 'link' | 'meta' | 'title'; -type TResource = { +type TResource< + T: 'stylesheet' | 'style' | 'script' | 'void', + S: null | {...}, +> = { type: T, instance: null | Instance, count: number, + state: S, }; -type StylesheetResource = TResource<'stylesheet'>; -type StyleTagResource = TResource<'style'>; +type StylesheetResource = TResource<'stylesheet', StylesheetState>; +type StyleTagResource = TResource<'style', null>; type StyleResource = StyleTagResource | StylesheetResource; -type ScriptResource = TResource<'script'>; -type VoidResource = TResource<'void'>; +type ScriptResource = TResource<'script', null>; +type VoidResource = TResource<'void', null>; type Resource = StyleResource | ScriptResource | VoidResource; +type LoadingState = number; +const NotLoaded = /* */ 0b00; +const Loaded = /* */ 0b01; +const Errored = /* */ 0b10; +const Settled = /* */ 0b11; + +type StylesheetState = { + loading: LoadingState, + preload: null | HTMLLinkElement, + temp_suspensey: boolean, +}; + type StyleTagProps = { 'data-href': string, 'data-precedence': string, @@ -2133,11 +2132,19 @@ function preinit(href: string, options: PreinitOptions) { return; } + const state = { + loading: NotLoaded, + preload: null, + temp_suspensey: false, + }; + // Attempt to hydrate instance from DOM let instance: null | Instance = resourceRoot.querySelector( getStylesheetSelectorFromKey(key), ); - if (!instance) { + if (instance) { + state.loading = Loaded; + } else { // Construct a new instance and insert it const stylesheetProps = stylesheetPropsFromPreinitOptions( href, @@ -2149,9 +2156,21 @@ function preinit(href: string, options: PreinitOptions) { adoptPreloadPropsForStylesheet(stylesheetProps, preloadProps); } const ownerDocument = getDocumentFromRoot(resourceRoot); - instance = ownerDocument.createElement('link'); - markNodeAsHoistable(instance); - setInitialProperties(instance, 'link', stylesheetProps); + const link = (instance = ownerDocument.createElement('link')); + markNodeAsHoistable(link); + setInitialProperties(link, 'link', stylesheetProps); + + (link: any)._p = new Promise((resolve, reject) => { + link.onload = resolve; + link.onerror = reject; + }); + link.addEventListener('load', () => { + state.loading |= Loaded; + }); + link.addEventListener('error', () => { + state.loading |= Errored; + }); + insertStylesheet(instance, precedence, resourceRoot); } @@ -2160,6 +2179,7 @@ function preinit(href: string, options: PreinitOptions) { type: 'stylesheet', instance, count: 1, + state, }; styles.set(key, resource); return; @@ -2202,6 +2222,7 @@ function preinit(href: string, options: PreinitOptions) { type: 'script', instance, count: 1, + state: null, }; scripts.set(key, resource); return; @@ -2292,6 +2313,7 @@ export function getResource( type: 'style', instance: null, count: 0, + state: null, }; styles.set(key, resource); } @@ -2301,6 +2323,7 @@ export function getResource( type: 'void', instance: null, count: 0, + state: null, }; } case 'link': { @@ -2322,6 +2345,11 @@ export function getResource( type: 'stylesheet', instance: null, count: 0, + state: { + loading: NotLoaded, + preload: null, + temp_suspensey: pendingProps['data-suspensey'] === true, + }, }; styles.set(key, resource); if (!preloadPropsMap.has(key)) { @@ -2329,6 +2357,7 @@ export function getResource( ownerDocument, key, preloadPropsFromStylesheet(qualifiedProps), + resource.state, ); } } @@ -2348,6 +2377,7 @@ export function getResource( type: 'script', instance: null, count: 0, + state: null, }; scripts.set(key, resource); } @@ -2357,6 +2387,7 @@ export function getResource( type: 'void', instance: null, count: 0, + state: null, }; } default: { @@ -2406,11 +2437,11 @@ function stylesheetPropsFromRawProps( precedence: null, }; } - function preloadStylesheet( ownerDocument: Document, key: string, preloadProps: PreloadProps, + state: StylesheetState, ) { preloadPropsMap.set(key, preloadProps); @@ -2418,11 +2449,18 @@ function preloadStylesheet( // There is no matching stylesheet instance in the Document. // We will insert a preload now to kick off loading because // we expect this stylesheet to commit - if ( - null === - ownerDocument.querySelector(getPreloadStylesheetSelectorFromKey(key)) - ) { + const preloadEl = ownerDocument.querySelector( + getPreloadStylesheetSelectorFromKey(key), + ); + if (preloadEl) { + // If we find a preload already it was SSR'd and we won't have an actual + // loading state to track. For now we will just assume it is loaded + state.loading = Loaded; + } else { const instance = ownerDocument.createElement('link'); + state.preload = instance; + instance.addEventListener('load', () => (state.loading |= Loaded)); + instance.addEventListener('error', () => (state.loading |= Errored)); setInitialProperties(instance, 'link', preloadProps); markNodeAsHoistable(instance); (ownerDocument.head: any).appendChild(instance); @@ -2518,10 +2556,7 @@ export function acquireResource( (linkInstance: any)._p = new Promise((resolve, reject) => { linkInstance.onload = resolve; linkInstance.onerror = reject; - }).then( - () => ((linkInstance: any)._p.s = 'l'), - () => ((linkInstance: any)._p.s = 'e'), - ); + }); setInitialProperties(instance, 'link', stylesheetProps); insertStylesheet(instance, qualifiedProps.precedence, hoistableRoot); resource.instance = instance; @@ -2580,7 +2615,7 @@ export function releaseResource(resource: Resource): void { } function insertStylesheet( - instance: Instance, + instance: HTMLElement, precedence: string, root: HoistableRoot, ): void { @@ -2598,6 +2633,7 @@ function insertStylesheet( break; } } + if (prior) { // We get the prior from the document so we know it is in the tree. // We also know that links can't be the topmost Node so the parentNode @@ -3010,3 +3046,248 @@ export function isHostHoistableType( } return false; } + +export function maySuspendCommit(type: Type, props: Props): boolean { + return false; +} + +export function mayResourceSuspendCommit(resource: Resource): boolean { + return ( + resource.type === 'stylesheet' && resource.state.temp_suspensey === true + ); +} + +export function preloadInstance(type: Type, props: Props): boolean { + // Return true to indicate it's already loaded + return true; +} + +export function preloadResource(resource: Resource): boolean { + if ( + resource.type === 'stylesheet' && + (resource.state.loading & Settled) === NotLoaded + ) { + // we have not finished loading the underlying stylesheet yet. + return false; + } + // Return true to indicate it's already loaded + return true; +} + +type SuspendedState = { + stylesheets: Map, + preloadCount: number, + count: number, + unsuspend: null | (() => void), +}; +let suspendedState: null | SuspendedState = null; + +export function startSuspendingCommit(): void { + suspendedState = { + stylesheets: new Map(), + preloadCount: 0, + count: 0, + unsuspend: null, + }; +} + +export function suspendInstance(type: Type, props: Props): void { + return; +} + +function onPreloadComplete(this: SuspendedState) { + this.count--; + this.preloadCount--; + if (this.preloadCount === 0) { + insertSuspendedStylesheets(this, this.stylesheets); + } + unsuspendIfUnblocked(this); +} + +export function suspendResource( + hoistableRoot: HoistableRoot, + resource: Resource, + props: any, +): void { + if (suspendedState === null) { + throw new Error( + 'Internal React Error: suspendedState null when it was expected to exists. Please report this as a React bug.', + ); + } + const state = suspendedState; + if (resource.type === 'stylesheet' && resource.instance === null) { + const qualifiedProps: StylesheetQualifyingProps = props; + const key = getStyleKey(qualifiedProps.href); + + // Attempt to hydrate instance from DOM + let instance: null | Instance = hoistableRoot.querySelector( + getStylesheetSelectorFromKey(key), + ); + if (instance) { + // If this instance has a loading state it came from the Fizz runtime. + // If there is not loading state it is assumed to have been server rendered + // as part of the preamble and therefore synchronously loaded. It could have + // errored however which we still do not yet have a means to detect. For now + // we assume it is loaded. + const maybeLoadingState: ?Promise = (instance: any)._p; + if ( + maybeLoadingState !== null && + typeof maybeLoadingState === 'object' && + // $FlowFixMe[method-unbinding] + typeof maybeLoadingState.then === 'function' + ) { + const loadingState = maybeLoadingState; + state.count++; + const ping = onUnsuspend.bind(state); + loadingState.then(ping, ping); + } + resource.instance = instance; + markNodeAsHoistable(instance); + return; + } + + const ownerDocument = getDocumentFromRoot(hoistableRoot); + + const stylesheetProps = stylesheetPropsFromRawProps(props); + const preloadProps = preloadPropsMap.get(key); + if (preloadProps) { + adoptPreloadPropsForStylesheet(stylesheetProps, preloadProps); + } + + // Construct and insert a new instance + instance = ownerDocument.createElement('link'); + markNodeAsHoistable(instance); + const linkInstance: HTMLLinkElement = (instance: any); + (linkInstance: any)._p = new Promise((resolve, reject) => { + linkInstance.onload = resolve; + linkInstance.onerror = reject; + }); + setInitialProperties(instance, 'link', stylesheetProps); + resource.instance = instance; + state.stylesheets.set(resource, hoistableRoot); + + const preloadEl = resource.state.preload; + if (preloadEl && (resource.state.loading & Settled) === NotLoaded) { + state.count++; + state.preloadCount++; + const ping = onPreloadComplete.bind(state); + preloadEl.addEventListener('load', ping); + preloadEl.addEventListener('error', ping); + } + } +} + +export function waitForCommitToBeReady(): null | (Function => Function) { + if ( + suspendedState !== null && + (suspendedState.count > 0 || suspendedState.stylesheets.size > 0) + ) { + return commit => { + // This function is called synchronously immediately after returning so we elide + // the existence check for suspendedState; + const state: SuspendedState = (suspendedState: any); + state.unsuspend = commit; + // In theory we don't need to bind this because we should always cancel + // before starting a new wait + return cancelSuspend; + }; + } + return null; +} + +function cancelSuspend() { + // We expect cancelSuspend to be called before calling `startSuspendingCommit` againt. + // If this constraint is violated this will not work right and will need to be bound instead + const state: SuspendedState = (suspendedState: any); + state.unsuspend = null; +} + +function onUnsuspend(this: SuspendedState) { + this.count--; + unsuspendIfUnblocked(this); +} + +function unsuspendIfUnblocked(state: SuspendedState) { + if (state.count === 0 && state.unsuspend !== null) { + const unsuspend = state.unsuspend; + state.unsuspend = null; + unsuspend(); + } +} + +// This is typecast to non-null because it will always be set before read. +// it is important that this not be used except when the stack guarantees it exists. +// Currentlyt his is only during insertSuspendedStylesheet. +let precedencesByRoot: Map> = (null: any); + +function insertSuspendedStylesheets( + state: SuspendedState, + resources: Map, +): void { + precedencesByRoot = new Map(); + resources.forEach(insertStyleIntoRoot, state); + precedencesByRoot = (null: any); +} + +function insertStyleIntoRoot( + this: SuspendedState, + root: HoistableRoot, + resource: StylesheetResource, + map: Map, +) { + let last; + let precedences = precedencesByRoot.get(root); + if (!precedences) { + precedences = new Map(); + precedencesByRoot.set(root, precedences); + const nodes = root.querySelectorAll( + 'link[data-precedence],style[data-precedence]', + ); + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if ( + node.nodeName === 'link' || + // We omit style tags with media="not all" because they are not in the right position + // and will be hoisted by the Fizz runtime imminently. + node.getAttribute('media') !== 'not all' + ) { + precedences.set('p' + node.dataset.precedence, node); + last = node; + } + } + if (last) { + precedences.set('last', last); + } + } else { + last = precedences.get('last'); + } + + // We only call this after we have constructed an instance so we assume it here + const instance: HTMLLinkElement = (resource.instance: any); + // We will always have a precedence for stylesheet instances + const precedence: string = (instance.getAttribute('data-precedence'): any); + + const prior = precedences.get('p' + precedence) || last; + if (prior === last) { + precedences.set('last', instance); + } + precedences.set(precedence, instance); + + if (prior) { + (prior.parentNode: any).insertBefore(instance, prior.nextSibling); + } else { + const parent = + root.nodeType === DOCUMENT_NODE + ? ((((root: any): Document).head: any): Element) + : ((root: any): ShadowRoot); + parent.insertBefore(instance, parent.firstChild); + } + + const media = instance.getAttribute('media'); + if (!media || matchMedia(media).matches) { + this.count++; + const onComplete = onUnsuspend.bind(this); + instance.addEventListener('load', onComplete); + instance.addEventListener('error', onComplete); + } +} diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetExternalRuntime.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetExternalRuntime.js index 2662eac36203c..5600b1940f7ef 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetExternalRuntime.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetExternalRuntime.js @@ -6,8 +6,6 @@ import { clientRenderBoundary, completeBoundary, completeSegment, - LOADED, - ERRORED, } from './ReactDOMFizzInstructionSetShared'; export {clientRenderBoundary, completeBoundary, completeSegment}; @@ -46,10 +44,6 @@ export function completeBoundaryWithStyles( const dependencies = []; let href, precedence, attr, loadingState, resourceEl, media; - function setStatus(s) { - this['s'] = s; - } - // Sheets Mode let sheetMode = true; while (true) { @@ -84,14 +78,10 @@ export function completeBoundaryWithStyles( while ((attr = stylesheetDescriptor[j++])) { resourceEl.setAttribute(attr, stylesheetDescriptor[j++]); } - loadingState = resourceEl['_p'] = new Promise((re, rj) => { - resourceEl.onload = re; - resourceEl.onerror = rj; + loadingState = resourceEl['_p'] = new Promise((resolve, reject) => { + resourceEl.onload = resolve; + resourceEl.onerror = reject; }); - loadingState.then( - setStatus.bind(loadingState, LOADED), - setStatus.bind(loadingState, ERRORED), - ); // Save this resource element so we can bailout if it is used again resourceMap.set(href, resourceEl); } diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js index 317209cd6c21b..a7247fe0c5d60 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js @@ -6,6 +6,6 @@ export const clientRenderBoundary = export const completeBoundary = '$RC=function(b,c,e){c=document.getElementById(c);c.parentNode.removeChild(c);var a=document.getElementById(b);if(a){b=a.previousSibling;if(e)b.data="$!",a.setAttribute("data-dgst",e);else{e=b.parentNode;a=b.nextSibling;var f=0;do{if(a&&8===a.nodeType){var d=a.data;if("/$"===d)if(0===f)break;else f--;else"$"!==d&&"$?"!==d&&"$!"!==d||f++}d=a.nextSibling;e.removeChild(a);a=d}while(a);for(;c.firstChild;)e.insertBefore(c.firstChild,a);b.data="$"}b._reactRetry&&b._reactRetry()}};'; export const completeBoundaryWithStyles = - '$RM=new Map;\n$RR=function(t,u,y){function v(n){this.s=n}for(var w=$RC,p=$RM,q=new Map,r=document,g,b,h=r.querySelectorAll("link[data-precedence],style[data-precedence]"),x=[],k=0;b=h[k++];)"not all"===b.getAttribute("media")?x.push(b):("LINK"===b.tagName&&p.set(b.getAttribute("href"),b),q.set(b.dataset.precedence,g=b));b=0;h=[];var l,a;for(k=!0;;){if(k){var f=y[b++];if(!f){k=!1;b=0;continue}var c=!1,m=0;var e=f[m++];if(a=p.get(e)){var d=a._p;c=!0}else{a=r.createElement("link");a.href=e;a.rel=\n"stylesheet";for(a.dataset.precedence=l=f[m++];d=f[m++];)a.setAttribute(d,f[m++]);d=a._p=new Promise(function(n,z){a.onload=n;a.onerror=z});d.then(v.bind(d,"l"),v.bind(d,"e"));p.set(e,a)}e=a.getAttribute("media");!d||"l"===d.s||e&&!matchMedia(e).matches||h.push(d);if(c)continue}else{a=x[b++];if(!a)break;l=a.getAttribute("data-precedence");a.removeAttribute("media")}c=q.get(l)||g;c===g&&(g=a);q.set(l,a);c?c.parentNode.insertBefore(a,c.nextSibling):(c=r.head,c.insertBefore(a,c.firstChild))}Promise.all(h).then(w.bind(null,\nt,u,""),w.bind(null,t,u,"Resource failed to load"))};'; + '$RM=new Map;\n$RR=function(r,t,w){for(var u=$RC,n=$RM,p=new Map,q=document,g,b,h=q.querySelectorAll("link[data-precedence],style[data-precedence]"),v=[],k=0;b=h[k++];)"not all"===b.getAttribute("media")?v.push(b):("LINK"===b.tagName&&n.set(b.getAttribute("href"),b),p.set(b.dataset.precedence,g=b));b=0;h=[];var l,a;for(k=!0;;){if(k){var f=w[b++];if(!f){k=!1;b=0;continue}var c=!1,m=0;var d=f[m++];if(a=n.get(d)){var e=a._p;c=!0}else{a=q.createElement("link");a.href=d;a.rel="stylesheet";for(a.dataset.precedence=\nl=f[m++];e=f[m++];)a.setAttribute(e,f[m++]);e=a._p=new Promise(function(x,y){a.onload=x;a.onerror=y});n.set(d,a)}d=a.getAttribute("media");!e||"l"===e.s||d&&!matchMedia(d).matches||h.push(e);if(c)continue}else{a=v[b++];if(!a)break;l=a.getAttribute("data-precedence");a.removeAttribute("media")}c=p.get(l)||g;c===g&&(g=a);p.set(l,a);c?c.parentNode.insertBefore(a,c.nextSibling):(c=q.head,c.insertBefore(a,c.firstChild))}Promise.all(h).then(u.bind(null,r,t,""),u.bind(null,r,t,"Resource failed to load"))};'; export const completeSegment = '$RS=function(a,b){a=document.getElementById(a);b=document.getElementById(b);for(a.parentNode.removeChild(a);a.firstChild;)b.parentNode.insertBefore(a.firstChild,b);b.parentNode.removeChild(b)};'; diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineSource.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineSource.js index 4f4f02d706e30..511ee1db3fd9a 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineSource.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineSource.js @@ -8,8 +8,6 @@ import { clientRenderBoundary, completeBoundary, completeSegment, - LOADED, - ERRORED, } from './ReactDOMFizzInstructionSetShared'; export {clientRenderBoundary, completeBoundary, completeSegment}; @@ -49,10 +47,6 @@ export function completeBoundaryWithStyles( const dependencies = []; let href, precedence, attr, loadingState, resourceEl, media; - function setStatus(s) { - this['s'] = s; - } - // Sheets Mode let sheetMode = true; while (true) { @@ -87,14 +81,10 @@ export function completeBoundaryWithStyles( while ((attr = stylesheetDescriptor[j++])) { resourceEl.setAttribute(attr, stylesheetDescriptor[j++]); } - loadingState = resourceEl['_p'] = new Promise((re, rj) => { - resourceEl.onload = re; - resourceEl.onerror = rj; + loadingState = resourceEl['_p'] = new Promise((resolve, reject) => { + resourceEl.onload = resolve; + resourceEl.onerror = reject; }); - loadingState.then( - setStatus.bind(loadingState, LOADED), - setStatus.bind(loadingState, ERRORED), - ); // Save this resource element so we can bailout if it is used again resourceMap.set(href, resourceEl); } diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js index a2a8e402e2f27..e439a87a96140 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js @@ -8,8 +8,6 @@ export const SUSPENSE_START_DATA = '$'; export const SUSPENSE_END_DATA = '/$'; export const SUSPENSE_PENDING_START_DATA = '$?'; export const SUSPENSE_FALLBACK_START_DATA = '$!'; -export const LOADED = 'l'; -export const ERRORED = 'e'; // TODO: Symbols that are referenced outside this module use dynamic accessor // notation instead of dot notation to prevent Closure's advanced compilation diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index c3a6da112cb4c..3fbd5db63f000 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -2676,6 +2676,65 @@ body { ); }); + it('can delay commit until css resources load', async () => { + const root = ReactDOMClient.createRoot(container); + expect(getMeaningfulChildren(container)).toBe(undefined); + React.startTransition(() => { + root.render( + <> + +
hello
+ , + ); + }); + await waitForAll([]); + expect(getMeaningfulChildren(container)).toBe(undefined); + expect(getMeaningfulChildren(document.head)).toEqual( + , + ); + + const preload = document.querySelector('link[rel="preload"][as="style"]'); + const loadEvent = document.createEvent('Events'); + loadEvent.initEvent('load', true, true); + preload.dispatchEvent(loadEvent); + + // We expect that the stylesheet is inserted now but the commit has not happened yet. + expect(getMeaningfulChildren(container)).toBe(undefined); + expect(getMeaningfulChildren(document.head)).toEqual([ + , + , + ]); + + const stylesheet = document.querySelector( + 'link[rel="stylesheet"][data-precedence]', + ); + const loadEvent2 = document.createEvent('Events'); + loadEvent2.initEvent('load', true, true); + stylesheet.dispatchEvent(loadEvent2); + + // We expect that the commit finishes synchronously after the stylesheet loads. + expect(getMeaningfulChildren(container)).toEqual(
hello
); + expect(getMeaningfulChildren(document.head)).toEqual([ + , + , + ]); + }); + describe('ReactDOM.prefetchDNS(href)', () => { it('creates a dns-prefetch resource when called', async () => { function App({url}) { diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index 15d5d40d05462..b42f0ae8bdf2f 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -578,6 +578,12 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { return type === 'suspensey-thing' && typeof props.src === 'string'; }, + mayResourceSuspendCommit(resource: mixed): boolean { + throw new Error( + 'Resources are not implemented for React Noop yet. This method should not be called', + ); + }, + preloadInstance(type: string, props: Props): boolean { if (type !== 'suspensey-thing' || typeof props.src !== 'string') { throw new Error('Attempted to preload unexpected instance: ' + type); @@ -608,8 +614,21 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { } }, + preloadResource(resource: mixed): boolean { + throw new Error( + 'Resources are not implemented for React Noop yet. This method should not be called', + ); + }, + startSuspendingCommit, suspendInstance, + + suspendResource(resource: mixed): void { + throw new Error( + 'Resources are not implemented for React Noop yet. This method should not be called', + ); + }, + waitForCommitToBeReady, prepareRendererToRender() {}, diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index dfcc9720125af..f8ef96f8a5653 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -160,6 +160,7 @@ import { unmountHoistable, prepareToCommitHoistables, suspendInstance, + suspendResource, } from './ReactFiberHostConfig'; import { captureCommitPhaseError, @@ -4064,23 +4065,72 @@ export function commitPassiveUnmountEffects(finishedWork: Fiber): void { resetCurrentDebugFiberInDEV(); } -export function recursivelyAccumulateSuspenseyCommit(parentFiber: Fiber): void { +export function accumulateSuspenseyCommit(finishedWork: Fiber): void { + accumulateSuspenseyCommitOnFiber(finishedWork); +} + +function recursivelyAccumulateSuspenseyCommit(parentFiber: Fiber): void { if (parentFiber.subtreeFlags & SuspenseyCommit) { let child = parentFiber.child; while (child !== null) { - recursivelyAccumulateSuspenseyCommit(child); - switch (child.tag) { - case HostComponent: - case HostHoistable: { - if (child.flags & SuspenseyCommit) { - const type = child.type; - const props = child.memoizedProps; - suspendInstance(type, props); - } - break; + accumulateSuspenseyCommitOnFiber(child); + child = child.sibling; + } + } +} + +function accumulateSuspenseyCommitOnFiber(fiber: Fiber) { + switch (fiber.tag) { + case HostHoistable: { + recursivelyAccumulateSuspenseyCommit(fiber); + if (fiber.flags & SuspenseyCommit) { + if (fiber.memoizedState !== null) { + suspendResource( + // This should always be set by visiting HostRoot first + (currentHoistableRoot: any), + fiber.memoizedState, + fiber.memoizedProps, + ); + } else { + const type = fiber.type; + const props = fiber.memoizedProps; + suspendInstance(type, props); } } - child = child.sibling; + break; + } + case HostComponent: { + recursivelyAccumulateSuspenseyCommit(fiber); + if (fiber.flags & SuspenseyCommit) { + const type = fiber.type; + const props = fiber.memoizedProps; + suspendInstance(type, props); + } + break; + } + case HostRoot: { + if (enableFloat && supportsResources) { + const previousHoistableRoot = currentHoistableRoot; + currentHoistableRoot = getHoistableRoot(fiber.stateNode.containerInfo); + + recursivelyAccumulateSuspenseyCommit(fiber); + currentHoistableRoot = previousHoistableRoot; + break; + } + } + // eslint-disable-next-line-no-fallthrough + case HostPortal: { + if (enableFloat && supportsResources) { + const previousHoistableRoot = currentHoistableRoot; + currentHoistableRoot = getHoistableRoot(fiber.stateNode.containerInfo); + recursivelyAccumulateSuspenseyCommit(fiber); + currentHoistableRoot = previousHoistableRoot; + break; + } + } + // eslint-disable-next-line-no-fallthrough + default: { + recursivelyAccumulateSuspenseyCommit(fiber); } } } diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index d67c78103ff00..058a8c1ef55af 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -110,6 +110,7 @@ import { preparePortalMount, prepareScopeUpdate, maySuspendCommit, + mayResourceSuspendCommit, preloadInstance, } from './ReactFiberHostConfig'; import { @@ -521,7 +522,17 @@ function preloadInstanceAndSuspendIfNeeded( renderLanes: Lanes, ) { // Ask the renderer if this instance should suspend the commit. - if (!maySuspendCommit(type, props)) { + if (workInProgress.memoizedState !== null) { + if (!mayResourceSuspendCommit(workInProgress.memoizedState)) { + // If this flag was set previously, we can remove it. The flag represents + // whether this particular set of props might ever need to suspend. The + // safest thing to do is for shouldSuspendCommit to always return true, but + // if the renderer is reasonably confident that the underlying resource + // won't be evicted, it can return false as a performance optimization. + workInProgress.flags &= ~SuspenseyCommit; + return; + } + } else if (!maySuspendCommit(type, props)) { // If this flag was set previously, we can remove it. The flag represents // whether this particular set of props might ever need to suspend. The // safest thing to do is for maySuspendCommit to always return true, but diff --git a/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js b/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js index e45085e4ee7b0..9680d45dc1616 100644 --- a/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js +++ b/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js @@ -32,3 +32,6 @@ export const mountHoistable = shim; export const unmountHoistable = shim; export const createHoistableInstance = shim; export const prepareToCommitHoistables = shim; +export const mayResourceSuspendCommit = shim; +export const preloadResource = shim; +export const suspendResource = shim; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 2553c24ccc260..e00d63769062e 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -209,7 +209,7 @@ import { invokePassiveEffectMountInDEV, invokeLayoutEffectUnmountInDEV, invokePassiveEffectUnmountInDEV, - recursivelyAccumulateSuspenseyCommit, + accumulateSuspenseyCommit, } from './ReactFiberCommitWork'; import {enqueueUpdate} from './ReactFiberClassUpdateQueue'; import {resetContextDependencies} from './ReactFiberNewContext'; @@ -1444,7 +1444,7 @@ function commitRootWhenReady( // the suspensey resources. The renderer is responsible for accumulating // all the load events. This all happens in a single synchronous // transaction, so it track state in its own module scope. - recursivelyAccumulateSuspenseyCommit(finishedWork); + accumulateSuspenseyCommit(finishedWork); // At the end, ask the renderer if it's ready to commit, or if we should // suspend. If it's not ready, it will return a callback to subscribe to // a ready event. diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index 1360e4e0c634c..938b83080431a 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -220,6 +220,9 @@ export const unmountHoistable = $$$hostConfig.unmountHoistable; export const createHoistableInstance = $$$hostConfig.createHoistableInstance; export const prepareToCommitHoistables = $$$hostConfig.prepareToCommitHoistables; +export const mayResourceSuspendCommit = $$$hostConfig.mayResourceSuspendCommit; +export const preloadResource = $$$hostConfig.preloadResource; +export const suspendResource = $$$hostConfig.suspendResource; // ------------------- // Singletons diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 2427b1284d843..d48354c974202 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -459,5 +459,6 @@ "471": "BigInt (%s) is not yet supported as an argument to a Server Function.", "472": "Type %s is not supported as an argument to a Server Function.", "473": "React doesn't accept base64 encoded file uploads because we don't except form data passed from a browser to ever encode data that way. If that's the wrong assumption, we can easily fix it.", - "474": "Suspense Exception: This is not a real error, and should not leak into userspace. If you're seeing this, it's likely a bug in React." + "474": "Suspense Exception: This is not a real error, and should not leak into userspace. If you're seeing this, it's likely a bug in React.", + "475": "Internal React Error: suspendedState null when it was expected to exists. Please report this as a React bug." }