diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 2fcf5bf9a5..6470dcf14c 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -2358,6 +2358,7 @@ export function getResource( type: string, currentProps: any, pendingProps: any, + currentResource: null | Resource, ): null | Resource { const resourceRoot = getCurrentResourceRoot(); if (!resourceRoot) { @@ -2430,9 +2431,44 @@ export function getResource( ); } } + if (currentProps && currentResource === null) { + // This node was previously an Instance type and is becoming a Resource type + // For now we error because we don't support flavor changes + let diff = ''; + if (__DEV__) { + diff = ` + + - ${describeLinkForResourceErrorDEV(currentProps)} + + ${describeLinkForResourceErrorDEV(pendingProps)}`; + } + throw new Error( + 'Expected not to update to be updated to a stylehsheet with precedence.' + + ' Check the `rel`, `href`, and `precedence` props of this component.' + + ' Alternatively, check whether two different components render in the same slot or share the same key.' + + diff, + ); + } return resource; + } else { + if (currentProps && currentResource !== null) { + // This node was previously a Resource type and is becoming an Instance type + // For now we error because we don't support flavor changes + let diff = ''; + if (__DEV__) { + diff = ` + + - ${describeLinkForResourceErrorDEV(currentProps)} + + ${describeLinkForResourceErrorDEV(pendingProps)}`; + } + throw new Error( + 'Expected stylesheet with precedence to not be updated to a different kind of .' + + ' Check the `rel`, `href`, and `precedence` props of this component.' + + ' Alternatively, check whether two different components render in the same slot or share the same key.' + + diff, + ); + } + return null; } - return null; } case 'script': { const async = pendingProps.async; @@ -2473,6 +2509,49 @@ export function getResource( } } +function describeLinkForResourceErrorDEV(props: any) { + if (__DEV__) { + let describedProps = 0; + + let description = ' describedProps) { + description += ' ...'; + } + description += ' />'; + return description; + } + return ''; +} + function styleTagPropsFromRawProps( rawProps: StyleTagQualifyingProps, ): StyleTagProps { diff --git a/packages/react-dom/src/__tests__/ReactDOMHostComponentTransitions-test.js b/packages/react-dom/src/__tests__/ReactDOMHostComponentTransitions-test.js new file mode 100644 index 0000000000..1a484dba60 --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMHostComponentTransitions-test.js @@ -0,0 +1,123 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + * @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment + */ + +'use strict'; + +let JSDOM; +let React; +let ReactDOMClient; +let container; +let waitForAll; + +describe('ReactDOM HostSingleton', () => { + beforeEach(() => { + jest.resetModules(); + JSDOM = require('jsdom').JSDOM; + // Test Environment + const jsdom = new JSDOM( + '
', + { + runScripts: 'dangerously', + }, + ); + global.window = jsdom.window; + global.document = jsdom.window.document; + container = global.document.getElementById('container'); + + React = require('react'); + ReactDOMClient = require('react-dom/client'); + + const InternalTestUtils = require('internal-test-utils'); + waitForAll = InternalTestUtils.waitForAll; + }); + + it('errors when a hoistable component becomes a Resource', async () => { + const errors = []; + function onError(e) { + errors.push(e.message); + } + const root = ReactDOMClient.createRoot(container, { + onUncaughtError: onError, + }); + + root.render( +
+ +
, + ); + await waitForAll([]); + + root.render( +
+ +
, + ); + await waitForAll([]); + if (__DEV__) { + expect(errors).toEqual([ + `Expected not to update to be updated to a stylehsheet with precedence. Check the \`rel\`, \`href\`, and \`precedence\` props of this component. Alternatively, check whether two different components render in the same slot or share the same key. + + - + + `, + ]); + } else { + expect(errors).toEqual([ + 'Expected not to update to be updated to a stylehsheet with precedence. Check the `rel`, `href`, and `precedence` props of this component. Alternatively, check whether two different components render in the same slot or share the same key.', + ]); + } + }); + + it('errors when a hoistable Resource becomes an instance', async () => { + const errors = []; + function onError(e) { + errors.push(e.message); + } + const root = ReactDOMClient.createRoot(container, { + onUncaughtError: onError, + }); + + root.render( +
+ +
, + ); + await waitForAll([]); + const event = new window.Event('load'); + const preloads = document.querySelectorAll('link[rel="preload"]'); + for (let i = 0; i < preloads.length; i++) { + const node = preloads[i]; + node.dispatchEvent(event); + } + const stylesheets = document.querySelectorAll('link[rel="preload"]'); + for (let i = 0; i < stylesheets.length; i++) { + const node = stylesheets[i]; + node.dispatchEvent(event); + } + + root.render( +
+ +
, + ); + await waitForAll([]); + if (__DEV__) { + expect(errors).toEqual([ + `Expected stylesheet with precedence to not be updated to a different kind of . Check the \`rel\`, \`href\`, and \`precedence\` props of this component. Alternatively, check whether two different components render in the same slot or share the same key. + + - + + `, + ]); + } else { + expect(errors).toEqual([ + 'Expected stylesheet with precedence to not be updated to a different kind of . Check the `rel`, `href`, and `precedence` props of this component. Alternatively, check whether two different components render in the same slot or share the same key.', + ]); + } + }); +}); diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 6eeb7ab377..793a3fa942 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -1689,22 +1689,36 @@ function updateHostHoistable( renderLanes: Lanes, ) { markRef(current, workInProgress); - const currentProps = current === null ? null : current.memoizedProps; - const resource = (workInProgress.memoizedState = getResource( - workInProgress.type, - currentProps, - workInProgress.pendingProps, - )); + if (current === null) { - if (!getIsHydrating() && resource === null) { - // This is not a Resource Hoistable and we aren't hydrating so we construct the instance. - workInProgress.stateNode = createHoistableInstance( - workInProgress.type, - workInProgress.pendingProps, - getRootHostContainer(), - workInProgress, - ); + const resource = getResource( + workInProgress.type, + null, + workInProgress.pendingProps, + null, + ); + if (resource) { + workInProgress.memoizedState = resource; + } else { + if (!getIsHydrating()) { + // This is not a Resource Hoistable and we aren't hydrating so we construct the instance. + workInProgress.stateNode = createHoistableInstance( + workInProgress.type, + workInProgress.pendingProps, + getRootHostContainer(), + workInProgress, + ); + } } + } else { + // Get Resource may or may not return a resource. either way we stash the result + // on memoized state. + workInProgress.memoizedState = getResource( + workInProgress.type, + current.memoizedProps, + workInProgress.pendingProps, + current.memoizedState, + ); } // Resources never have reconciler managed children. It is possible for diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index a060d0f00a..4a671940ba 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -1052,7 +1052,6 @@ function completeWork( return null; } else { // This is a Hoistable Instance - // This must come at the very end of the complete phase. bubbleProperties(workInProgress); preloadInstanceAndSuspendIfNeeded( @@ -1064,21 +1063,18 @@ function completeWork( return null; } } else { - // We are updating. - const currentResource = current.memoizedState; - if (nextResource !== currentResource) { - // We are transitioning to, from, or between Hoistable Resources - // and require an update - markUpdate(workInProgress); - } - if (nextResource !== null) { - // This is a Hoistable Resource - // This must come at the very end of the complete phase. - - bubbleProperties(workInProgress); - if (nextResource === currentResource) { - workInProgress.flags &= ~MaySuspendCommit; - } else { + // This is an update. + if (nextResource) { + // This is a Resource + if (nextResource !== current.memoizedState) { + // we have a new Resource. we need to update + markUpdate(workInProgress); + // This must come at the very end of the complete phase. + bubbleProperties(workInProgress); + // This must come at the very end of the complete phase, because it might + // throw to suspend, and if the resource immediately loads, the work loop + // will resume rendering as if the work-in-progress completed. So it must + // fully complete. preloadResourceAndSuspendIfNeeded( workInProgress, nextResource, @@ -1086,10 +1082,15 @@ function completeWork( newProps, renderLanes, ); + return null; + } else { + // This must come at the very end of the complete phase. + bubbleProperties(workInProgress); + workInProgress.flags &= ~MaySuspendCommit; + return null; } - return null; } else { - // This is a Hoistable Instance + // This is an Instance // We may have props to update on the Hoistable instance. if (supportsMutation) { const oldProps = current.memoizedProps; @@ -1107,7 +1108,6 @@ function completeWork( renderLanes, ); } - // This must come at the very end of the complete phase. bubbleProperties(workInProgress); preloadInstanceAndSuspendIfNeeded( diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 9bb82658ac..221c62a871 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -512,5 +512,7 @@ "524": "Values cannot be passed to next() of AsyncIterables passed to Client Components.", "525": "A React Element from an older version of React was rendered. This is not supported. It can happen if:\n- Multiple copies of the \"react\" package is used.\n- A library pre-bundled an old copy of \"react\" or \"react/jsx-runtime\".\n- A compiler tries to \"inline\" JSX instead of using the runtime.", "526": "Could not reference an opaque temporary reference. This is likely due to misconfiguring the temporaryReferences options on the server.", - "527": "Incompatible React versions: The \"react\" and \"react-dom\" packages must have the exact same version. Instead got:\n - react: %s\n - react-dom: %s\nLearn more: https://react.dev/warnings/version-mismatch" + "527": "Incompatible React versions: The \"react\" and \"react-dom\" packages must have the exact same version. Instead got:\n - react: %s\n - react-dom: %s\nLearn more: https://react.dev/warnings/version-mismatch", + "528": "Expected not to update to be updated to a stylehsheet with precedence. Check the `rel`, `href`, and `precedence` props of this component. Alternatively, check whether two different components render in the same slot or share the same key.%s", + "529": "Expected stylesheet with precedence to not be updated to a different kind of . Check the `rel`, `href`, and `precedence` props of this component. Alternatively, check whether two different components render in the same slot or share the same key.%s" }