diff --git a/fixtures/flight/package.json b/fixtures/flight/package.json index 6d48c311cce3e..2aa38c85cabb1 100644 --- a/fixtures/flight/package.json +++ b/fixtures/flight/package.json @@ -71,10 +71,10 @@ "scripts": { "predev": "cp -r ../../build/oss-experimental/* ./node_modules/ && rm -rf node_modules/.cache;", "prebuild": "cp -r ../../build/oss-experimental/* ./node_modules/ && rm -rf node_modules/.cache;", - "dev": "concurrently \"npm run dev:region\" \"npm run dev:global\"", + "dev": "concurrently \"yarn run dev:region\" \"yarn run dev:global\"", "dev:global": "NODE_ENV=development BUILD_PATH=dist node --experimental-loader ./loader/global.js --inspect=127.0.0.1:9230 server/global", "dev:region": "NODE_ENV=development BUILD_PATH=dist nodemon --watch src --watch dist -- --enable-source-maps --experimental-loader ./loader/region.js --conditions=react-server --inspect=127.0.0.1:9229 server/region", - "start": "node scripts/build.js && concurrently \"npm run start:region\" \"npm run start:global\"", + "start": "node scripts/build.js && concurrently \"yarn run start:region\" \"yarn run start:global\"", "start:global": "NODE_ENV=production node --experimental-loader ./loader/global.js server/global", "start:region": "NODE_ENV=production node --experimental-loader ./loader/region.js --conditions=react-server server/region", "build": "node scripts/build.js", diff --git a/packages/internal-test-utils/__tests__/ReactInternalTestUtils-test.js b/packages/internal-test-utils/__tests__/ReactInternalTestUtils-test.js index 3c10125186832..b24741477866b 100644 --- a/packages/internal-test-utils/__tests__/ReactInternalTestUtils-test.js +++ b/packages/internal-test-utils/__tests__/ReactInternalTestUtils-test.js @@ -2169,6 +2169,29 @@ describe('ReactInternalTestUtils console assertions', () => { + Bye in div (at **)" `); }); + + // @gate __DEV__ + it('fails if last received error containing "undefined" is not included', () => { + const message = expectToThrowFailure(() => { + console.error('Hi'); + console.error( + "TypeError: Cannot read properties of undefined (reading 'stack')\n" + + ' in Foo (at **)' + ); + assertConsoleErrorDev([['Hi', {withoutStack: true}]]); + }); + expect(message).toMatchInlineSnapshot(` + "assertConsoleErrorDev(expected) + + Unexpected error(s) recorded. + + - Expected errors + + Received errors + + Hi + + TypeError: Cannot read properties of undefined (reading 'stack') in Foo (at **)" + `); + }); // @gate __DEV__ it('fails if only error does not contain a stack', () => { const message = expectToThrowFailure(() => { diff --git a/packages/internal-test-utils/consoleMock.js b/packages/internal-test-utils/consoleMock.js index ecb97b3a03059..743519590e37e 100644 --- a/packages/internal-test-utils/consoleMock.js +++ b/packages/internal-test-utils/consoleMock.js @@ -355,7 +355,7 @@ export function createLogAssertion( let argIndex = 0; // console.* could have been called with a non-string e.g. `console.error(new Error())` // eslint-disable-next-line react-internal/safe-string-coercion - String(format).replace(/%s|%c/g, () => argIndex++); + String(format).replace(/%s|%c|%o/g, () => argIndex++); if (argIndex !== args.length) { if (format.includes('%c%s')) { // We intentionally use mismatching formatting when printing badging because we don't know @@ -382,8 +382,9 @@ export function createLogAssertion( // Main logic to check if log is expected, with the component stack. if ( - normalizedMessage === expectedMessage || - normalizedMessage.includes(expectedMessage) + typeof expectedMessage === 'string' && + (normalizedMessage === expectedMessage || + normalizedMessage.includes(expectedMessage)) ) { if (isLikelyAComponentStack(normalizedMessage)) { if (expectedWithoutStack === true) { diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index f5d202fe0164b..e75b840a5af5e 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -367,6 +367,7 @@ export function getInternalReactConstants(version: string): { ReactPriorityLevels: ReactPriorityLevelsType, ReactTypeOfWork: WorkTagMap, StrictModeBits: number, + SuspenseyImagesMode: number, } { // ********************************************************** // The section below is copied from files in React repo. @@ -407,6 +408,8 @@ export function getInternalReactConstants(version: string): { StrictModeBits = 0b10; } + const SuspenseyImagesMode = 0b0100000; + let ReactTypeOfWork: WorkTagMap = ((null: any): WorkTagMap); // ********************************************************** @@ -820,6 +823,7 @@ export function getInternalReactConstants(version: string): { ReactPriorityLevels, ReactTypeOfWork, StrictModeBits, + SuspenseyImagesMode, }; } @@ -988,6 +992,7 @@ export function attach( ReactPriorityLevels, ReactTypeOfWork, StrictModeBits, + SuspenseyImagesMode, } = getInternalReactConstants(version); const { ActivityComponent, @@ -3345,6 +3350,114 @@ export function attach( insertSuspendedBy(asyncInfo); } + function trackDebugInfoFromHostComponent( + devtoolsInstance: DevToolsInstance, + fiber: Fiber, + ): void { + if (fiber.tag !== HostComponent) { + return; + } + if ((fiber.mode & SuspenseyImagesMode) === 0) { + // In any released version, Suspensey Images are only enabled inside a ViewTransition + // subtree, which is enabled by the SuspenseyImagesMode. + // TODO: If we ever enable the enableSuspenseyImages flag then it would be enabled for + // all images and we'd need some other check for if the version of React has that enabled. + return; + } + + const type = fiber.type; + const props: { + src?: string, + onLoad?: (event: any) => void, + loading?: 'eager' | 'lazy', + ... + } = fiber.memoizedProps; + + const maySuspendCommit = + type === 'img' && + props.src != null && + props.src !== '' && + props.onLoad == null && + props.loading !== 'lazy'; + + // Note: We don't track "maySuspendCommitOnUpdate" separately because it doesn't matter if + // it didn't suspend this particular update if it would've suspended if it mounted in this + // state, since we're tracking the dependencies inside the current state. + + if (!maySuspendCommit) { + return; + } + + const instance = fiber.stateNode; + if (instance == null) { + // Should never happen. + return; + } + + // Unlike props.src, currentSrc will be fully qualified which we need for comparison below. + // Unlike instance.src it will be resolved into the media queries currently matching which is + // the state we're inspecting. + const src = instance.currentSrc; + if (typeof src !== 'string' || src === '') { + return; + } + let start = -1; + let end = -1; + let fileSize = 0; + // $FlowFixMe[method-unbinding] + if (typeof performance.getEntriesByType === 'function') { + // We may be able to collect the start and end time of this resource from Performance Observer. + const resourceEntries = performance.getEntriesByType('resource'); + for (let i = 0; i < resourceEntries.length; i++) { + const resourceEntry = resourceEntries[i]; + if (resourceEntry.name === src) { + start = resourceEntry.startTime; + end = start + resourceEntry.duration; + // $FlowFixMe[prop-missing] + fileSize = (resourceEntry.encodedBodySize: any) || 0; + } + } + } + // A representation of the image data itself. + // TODO: We could render a little preview in the front end from the resource API. + const value: { + currentSrc: string, + naturalWidth?: number, + naturalHeight?: number, + fileSize?: number, + } = { + currentSrc: src, + }; + if (instance.naturalWidth > 0 && instance.naturalHeight > 0) { + // The intrinsic size of the file value itself, if it's loaded + value.naturalWidth = instance.naturalWidth; + value.naturalHeight = instance.naturalHeight; + } + if (fileSize > 0) { + // Cross-origin images won't have a file size that we can access. + value.fileSize = fileSize; + } + const promise = Promise.resolve(value); + (promise: any).status = 'fulfilled'; + (promise: any).value = value; + const ioInfo: ReactIOInfo = { + name: 'img', + start, + end, + value: promise, + // $FlowFixMe: This field doesn't usually take a Fiber but we're only using inside this file. + owner: fiber, // Allow linking to the if it's not filtered. + }; + const asyncInfo: ReactAsyncInfo = { + awaited: ioInfo, + // $FlowFixMe: This field doesn't usually take a Fiber but we're only using inside this file. + owner: fiber._debugOwner == null ? null : fiber._debugOwner, + debugStack: fiber._debugStack == null ? null : fiber._debugStack, + debugTask: fiber._debugTask == null ? null : fiber._debugTask, + }; + insertSuspendedBy(asyncInfo); + } + function mountVirtualChildrenRecursively( firstChild: Fiber, lastChild: null | Fiber, // non-inclusive @@ -3619,6 +3732,7 @@ export function attach( throw new Error('Did not expect a host hoistable to be the root'); } aquireHostInstance(nearestInstance, fiber.stateNode); + trackDebugInfoFromHostComponent(nearestInstance, fiber); } if (fiber.tag === OffscreenComponent && fiber.memoizedState !== null) { @@ -4447,20 +4561,22 @@ export function attach( aquireHostResource(nearestInstance, nextFiber.memoizedState); trackDebugInfoFromHostResource(nearestInstance, nextFiber); } else if ( - (nextFiber.tag === HostComponent || - nextFiber.tag === HostText || - nextFiber.tag === HostSingleton) && - prevFiber.stateNode !== nextFiber.stateNode + nextFiber.tag === HostComponent || + nextFiber.tag === HostText || + nextFiber.tag === HostSingleton ) { - // In persistent mode, it's possible for the stateNode to update with - // a new clone. In that case we need to release the old one and aquire - // new one instead. const nearestInstance = reconcilingParent; if (nearestInstance === null) { throw new Error('Did not expect a host hoistable to be the root'); } - releaseHostInstance(nearestInstance, prevFiber.stateNode); - aquireHostInstance(nearestInstance, nextFiber.stateNode); + if (prevFiber.stateNode !== nextFiber.stateNode) { + // In persistent mode, it's possible for the stateNode to update with + // a new clone. In that case we need to release the old one and aquire + // new one instead. + releaseHostInstance(nearestInstance, prevFiber.stateNode); + aquireHostInstance(nearestInstance, nextFiber.stateNode); + } + trackDebugInfoFromHostComponent(nearestInstance, nextFiber); } let updateFlags = NoUpdate; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js index 7b19908cc8c4a..fd068c0ad1856 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js @@ -24,7 +24,6 @@ import FetchFileWithCachingContext from './FetchFileWithCachingContext'; import {symbolicateSourceWithCache} from 'react-devtools-shared/src/symbolicateSource'; import OpenInEditorButton from './OpenInEditorButton'; import InspectedElementViewSourceButton from './InspectedElementViewSourceButton'; -import Skeleton from './Skeleton'; import useEditorURL from '../useEditorURL'; import styles from './InspectedElement.css'; @@ -203,7 +202,9 @@ export default function InspectedElementWrapper(_: Props): React.Node { } return ( -
+
{strictModeBadge} @@ -232,13 +233,11 @@ export default function InspectedElementWrapper(_: Props): React.Node { !!editorURL && source != null && symbolicatedSourcePromise != null && ( - }> - - + )} {canToggleError && ( @@ -294,9 +293,6 @@ export default function InspectedElementWrapper(_: Props): React.Node { {inspectedElement !== null && symbolicatedSourcePromise != null && (
source
- }> + + + + }>
rendered by
- - {showStack ? : null} - {showOwnersList && - owners?.map(owner => ( - - - {owner.stack != null && owner.stack.length > 0 ? ( - - ) : null} - - ))} - - {rootType !== null && ( -
{rootType}
- )} - {rendererLabel !== null && ( -
{rendererLabel}
- )} + + +
+ }> + {showStack ? : null} + {showOwnersList && + owners?.map(owner => ( + + + {owner.stack != null && owner.stack.length > 0 ? ( + + ) : null} + + ))} + + {rootType !== null && ( +
{rootType}
+ )} + {rendererLabel !== null && ( +
{rendererLabel}
+ )} +
)} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementViewSourceButton.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementViewSourceButton.js index 23d4cf96c8277..ee2fbe6c4d209 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementViewSourceButton.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementViewSourceButton.js @@ -11,7 +11,6 @@ import * as React from 'react'; import ButtonIcon from '../ButtonIcon'; import Button from '../Button'; -import Skeleton from './Skeleton'; import type {ReactFunctionLocation} from 'shared/ReactTypes'; @@ -27,7 +26,12 @@ function InspectedElementViewSourceButton({ symbolicatedSourcePromise, }: Props): React.Node { return ( - }> + + + + }> + + Loading source maps... + + }> + + + ); +} + export default OpenInEditorButton; diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 19a49dcd32b98..bc9c259b3922f 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -3354,6 +3354,27 @@ function renderModelDestructive( task.debugOwner = element._owner; task.debugStack = element._debugStack; task.debugTask = element._debugTask; + if ( + element._owner === undefined || + element._debugStack === undefined || + element._debugTask === undefined + ) { + let key = ''; + if (element.key !== null) { + key = ' key="' + element.key + '"'; + } + + console.error( + 'Attempted to render <%s%s> without development properties. ' + + 'This is not supported. It can happen if:' + + '\n- The element is created with a production version of React but rendered in development.' + + '\n- The element was cloned with a custom function instead of `React.cloneElement`.\n' + + 'The props of this element may help locate this element: %o', + element.type, + key, + element.props, + ); + } // TODO: Pop this. Since we currently don't have a point where we can pop the stack // this debug information will be used for errors inside sibling properties that // are not elements. Leading to the wrong attribution on the server. We could fix diff --git a/packages/react-server/src/__tests__/ReactFlightServer-test.js b/packages/react-server/src/__tests__/ReactFlightServer-test.js index b81a793a7f29d..c924a52c4f417 100644 --- a/packages/react-server/src/__tests__/ReactFlightServer-test.js +++ b/packages/react-server/src/__tests__/ReactFlightServer-test.js @@ -36,6 +36,7 @@ let ReactNoopFlightServer; let Scheduler; let advanceTimersByTime; let assertLog; +let assertConsoleErrorDev; describe('ReactFlight', () => { beforeEach(() => { @@ -64,6 +65,7 @@ describe('ReactFlight', () => { Scheduler = require('scheduler'); const InternalTestUtils = require('internal-test-utils'); assertLog = InternalTestUtils.assertLog; + assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev; }); afterEach(() => { @@ -175,4 +177,26 @@ describe('ReactFlight', () => { stackTwo: '\n in OwnerStackDelayed (at **)' + '\n in App (at **)', }); }); + + it('logs an error when prod elements are rendered', async () => { + const element = ReactServer.createElement('span', { + key: 'one', + children: 'Free!', + }); + ReactNoopFlightServer.render( + // bad clone + {...element}, + ); + + assertConsoleErrorDev([ + [ + 'Attempted to render without development properties. This is not supported. It can happen if:' + + '\n- The element is created with a production version of React but rendered in development.' + + '\n- The element was cloned with a custom function instead of `React.cloneElement`.\n' + + "The props of this element may help locate this element: { children: 'Free!', [key]: [Getter] }", + {withoutStack: true}, + ], + "TypeError: Cannot read properties of undefined (reading 'stack')", + ]); + }); }); diff --git a/packages/shared/ReactIODescription.js b/packages/shared/ReactIODescription.js index 10c888213ddb7..7fa6bb243936f 100644 --- a/packages/shared/ReactIODescription.js +++ b/packages/shared/ReactIODescription.js @@ -26,6 +26,10 @@ export function getIODescription(value: any): string { return value.url; } else if (typeof value.href === 'string') { return value.href; + } else if (typeof value.src === 'string') { + return value.src; + } else if (typeof value.currentSrc === 'string') { + return value.currentSrc; } else if (typeof value.command === 'string') { return value.command; } else if (