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 ( -