diff --git a/.eslintrc.js b/.eslintrc.js index 4f902576ad82c..9d142d359afc3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -517,6 +517,14 @@ module.exports = { __IS_INTERNAL_VERSION__: 'readonly', }, }, + { + files: ['packages/react-devtools-*/**/*.js'], + excludedFiles: '**/__tests__/**/*.js', + plugins: ['eslint-plugin-react-hooks-published'], + rules: { + 'react-hooks-published/rules-of-hooks': ERROR, + }, + }, { files: ['packages/eslint-plugin-react-hooks/src/**/*'], extends: ['plugin:@typescript-eslint/recommended'], diff --git a/compiler/apps/playground/__tests__/e2e/page.spec.ts b/compiler/apps/playground/__tests__/e2e/page.spec.ts index 4a10e8603bb28..94abe40eeb864 100644 --- a/compiler/apps/playground/__tests__/e2e/page.spec.ts +++ b/compiler/apps/playground/__tests__/e2e/page.spec.ts @@ -23,7 +23,8 @@ function formatPrint(data: Array): Promise { async function expandConfigs(page: Page): Promise { const expandButton = page.locator('[title="Expand config editor"]'); - expandButton.click(); + await expandButton.click(); + await page.waitForSelector('.monaco-editor-config', {state: 'visible'}); } const TEST_SOURCE = `export default function TestComponent({ x }) { diff --git a/compiler/apps/playground/components/AccordionWindow.tsx b/compiler/apps/playground/components/AccordionWindow.tsx index bebbb0c4787a5..197f543b4ab4a 100644 --- a/compiler/apps/playground/components/AccordionWindow.tsx +++ b/compiler/apps/playground/components/AccordionWindow.tsx @@ -18,19 +18,21 @@ export default function AccordionWindow(props: { changedPasses: Set; }): React.ReactElement { return ( -
- {Array.from(props.tabs.keys()).map(name => { - return ( - - ); - })} +
+
+ {Array.from(props.tabs.keys()).map(name => { + return ( + + ); + })} +
); } diff --git a/compiler/apps/playground/components/Editor/ConfigEditor.tsx b/compiler/apps/playground/components/Editor/ConfigEditor.tsx index c70cd10ba53e8..d922f27c97864 100644 --- a/compiler/apps/playground/components/Editor/ConfigEditor.tsx +++ b/compiler/apps/playground/components/Editor/ConfigEditor.tsx @@ -9,12 +9,19 @@ import MonacoEditor, {loader, type Monaco} from '@monaco-editor/react'; import {PluginOptions} from 'babel-plugin-react-compiler'; import type {editor} from 'monaco-editor'; import * as monaco from 'monaco-editor'; -import React, {useState, useRef} from 'react'; +import React, { + useState, + useRef, + unstable_ViewTransition as ViewTransition, + unstable_addTransitionType as addTransitionType, + startTransition, +} from 'react'; import {Resizable} from 're-resizable'; import {useStore, useStoreDispatch} from '../StoreContext'; import {monacoOptions} from './monacoOptions'; import {IconChevron} from '../Icons/IconChevron'; import prettyFormat from 'pretty-format'; +import {CONFIG_PANEL_TRANSITION} from '../../lib/transitionTypes'; // @ts-expect-error - webpack asset/source loader handles .d.ts files as strings import compilerTypeDefs from 'babel-plugin-react-compiler/dist/index.d.ts'; @@ -36,7 +43,12 @@ export default function ConfigEditor({ display: isExpanded ? 'block' : 'none', }}> { + startTransition(() => { + addTransitionType(CONFIG_PANEL_TRANSITION); + setIsExpanded(false); + }); + }} appliedOptions={appliedOptions} />
@@ -44,7 +56,14 @@ export default function ConfigEditor({ style={{ display: !isExpanded ? 'block' : 'none', }}> - + { + startTransition(() => { + addTransitionType(CONFIG_PANEL_TRANSITION); + setIsExpanded(true); + }); + }} + /> ); @@ -54,7 +73,7 @@ function ExpandedEditor({ onToggle, appliedOptions, }: { - onToggle: (expanded: boolean) => void; + onToggle: () => void; appliedOptions: PluginOptions | null; }): React.ReactElement { const store = useStore(); @@ -111,90 +130,93 @@ function ExpandedEditor({ : 'Invalid configs'; return ( - -
-
onToggle(false)} - style={{ - top: '50%', - marginTop: '-32px', - right: '-32px', - borderTopLeftRadius: 0, - borderBottomLeftRadius: 0, - }}> - -
- -
-
-

- Config Overrides -

-
-
- + + +
+
+
-
-
-
-

- Applied Configs -

+ +
+
+

+ Config Overrides +

+
+
+ +
-
- +
+
+

+ Applied Configs +

+
+
+ +
-
- + + ); } function CollapsedEditor({ onToggle, }: { - onToggle: (expanded: boolean) => void; + onToggle: () => void; }): React.ReactElement { return (
onToggle(true)} + onClick={onToggle} style={{ top: '50%', marginTop: '-32px', diff --git a/compiler/apps/playground/components/Editor/EditorImpl.tsx b/compiler/apps/playground/components/Editor/EditorImpl.tsx index 696bbd2559c11..9f000f85564a2 100644 --- a/compiler/apps/playground/components/Editor/EditorImpl.tsx +++ b/compiler/apps/playground/components/Editor/EditorImpl.tsx @@ -343,12 +343,8 @@ export default function Editor(): JSX.Element {
-
- -
-
- -
+ +
diff --git a/compiler/apps/playground/components/Editor/Input.tsx b/compiler/apps/playground/components/Editor/Input.tsx index 6cded7656b06f..8c37b12975b8b 100644 --- a/compiler/apps/playground/components/Editor/Input.tsx +++ b/compiler/apps/playground/components/Editor/Input.tsx @@ -13,11 +13,17 @@ import { import invariant from 'invariant'; import type {editor} from 'monaco-editor'; import * as monaco from 'monaco-editor'; -import {useEffect, useState} from 'react'; +import { + useEffect, + useState, + unstable_ViewTransition as ViewTransition, +} from 'react'; import {renderReactCompilerMarkers} from '../../lib/reactCompilerMonacoDiagnostics'; import {useStore, useStoreDispatch} from '../StoreContext'; import TabbedWindow from '../TabbedWindow'; import {monacoOptions} from './monacoOptions'; +import {CONFIG_PANEL_TRANSITION} from '../../lib/transitionTypes'; + // @ts-expect-error TODO: Make TS recognize .d.ts files, in addition to loading them with webpack. import React$Types from '../../node_modules/@types/react/index.d.ts'; @@ -155,9 +161,13 @@ export default function Input({errors, language}: Props): JSX.Element { const [activeTab, setActiveTab] = useState('Input'); return ( -
-
-
+ +
+
-
+ ); } diff --git a/compiler/apps/playground/components/Editor/Output.tsx b/compiler/apps/playground/components/Editor/Output.tsx index dcc8dea5c83bf..0ccc0747a6931 100644 --- a/compiler/apps/playground/components/Editor/Output.tsx +++ b/compiler/apps/playground/components/Editor/Output.tsx @@ -20,11 +20,19 @@ import parserBabel from 'prettier/plugins/babel'; import * as prettierPluginEstree from 'prettier/plugins/estree'; import * as prettier from 'prettier/standalone'; import {type Store} from '../../lib/stores'; -import {memo, ReactNode, use, useState, Suspense} from 'react'; +import { + memo, + ReactNode, + use, + useState, + Suspense, + unstable_ViewTransition as ViewTransition, +} from 'react'; import AccordionWindow from '../AccordionWindow'; import TabbedWindow from '../TabbedWindow'; import {monacoOptions} from './monacoOptions'; import {BabelFileResult} from '@babel/core'; +import {CONFIG_PANEL_TRANSITION} from '../../lib/transitionTypes'; import {LRUCache} from 'lru-cache'; const MemoizedOutput = memo(Output); @@ -280,22 +288,34 @@ function OutputContent({store, compilerOutput}: Props): JSX.Element { if (!store.showInternals) { return ( - + + + ); } return ( - + + + ); } diff --git a/compiler/apps/playground/components/TabbedWindow.tsx b/compiler/apps/playground/components/TabbedWindow.tsx index d2335687c2206..1fd5f188c7de1 100644 --- a/compiler/apps/playground/components/TabbedWindow.tsx +++ b/compiler/apps/playground/components/TabbedWindow.tsx @@ -17,26 +17,28 @@ export default function TabbedWindow({ onTabChange: (tab: string) => void; }): React.ReactElement { return ( -
-
- {Array.from(tabs.keys()).map(tab => { - const isActive = activeTab === tab; - return ( - - ); - })} -
-
- {tabs.get(activeTab)} +
+
+
+ {Array.from(tabs.keys()).map(tab => { + const isActive = activeTab === tab; + return ( + + ); + })} +
+
+ {tabs.get(activeTab)} +
); diff --git a/compiler/apps/playground/lib/transitionTypes.ts b/compiler/apps/playground/lib/transitionTypes.ts new file mode 100644 index 0000000000000..0c39e586fed54 --- /dev/null +++ b/compiler/apps/playground/lib/transitionTypes.ts @@ -0,0 +1,8 @@ +/** + * 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. + */ + +export const CONFIG_PANEL_TRANSITION = 'config-panel'; diff --git a/compiler/apps/playground/next.config.js b/compiler/apps/playground/next.config.js index fc8a9492e4ed7..f34f958ec6d2d 100644 --- a/compiler/apps/playground/next.config.js +++ b/compiler/apps/playground/next.config.js @@ -11,6 +11,7 @@ const path = require('path'); const nextConfig = { experimental: { reactCompiler: true, + viewTransition: true, }, reactStrictMode: true, webpack: (config, options) => { diff --git a/compiler/apps/playground/styles/globals.css b/compiler/apps/playground/styles/globals.css index c4558eb8b8a91..e8b92e6c7be48 100644 --- a/compiler/apps/playground/styles/globals.css +++ b/compiler/apps/playground/styles/globals.css @@ -69,3 +69,42 @@ scrollbar-width: none; /* Firefox */ } } + +::view-transition-old(.slide-in) { + animation-name: slideOutLeft; +} +::view-transition-new(.slide-in) { + animation-name: slideInLeft; +} +::view-transition-group(.slide-in) { + z-index: 1; +} + +@keyframes slideOutLeft { + from { + transform: translateX(0); + } + to { + transform: translateX(-100%); + } +} +@keyframes slideInLeft { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} + +::view-transition-old(.container), +::view-transition-new(.container) { + height: 100%; +} + +::view-transition-old(.accordion-container), +::view-transition-new(.accordion-container) { + height: 100%; + object-fit: none; + object-position: left; +} diff --git a/package.json b/package.json index 3439ed756a346..7bb0c2c5c9350 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "eslint-plugin-no-for-of-loops": "^1.0.0", "eslint-plugin-no-function-declare-after-return": "^1.0.0", "eslint-plugin-react": "^6.7.1", + "eslint-plugin-react-hooks-published": "npm:eslint-plugin-react-hooks@^5.2.0", "eslint-plugin-react-internal": "link:./scripts/eslint-rules", "fbjs-scripts": "^3.0.1", "filesize": "^6.0.1", diff --git a/packages/react-devtools-shared/src/backend/agent.js b/packages/react-devtools-shared/src/backend/agent.js index 98091a06d6a8f..5a7d6eb836e03 100644 --- a/packages/react-devtools-shared/src/backend/agent.js +++ b/packages/react-devtools-shared/src/backend/agent.js @@ -29,7 +29,7 @@ import type { } from './types'; import type {ComponentFilter} from 'react-devtools-shared/src/frontend/types'; import type {GroupItem} from './views/TraceUpdates/canvas'; -import {isReactNativeEnvironment} from './utils'; +import {gte, isReactNativeEnvironment} from './utils'; import { sessionStorageGetItem, sessionStorageRemoveItem, @@ -739,7 +739,7 @@ export default class Agent extends EventEmitter<{ if (renderer !== null) { const devRenderer = renderer.bundleType === 1; const enableSuspenseTab = - devRenderer && renderer.version.includes('-experimental-'); + devRenderer && gte(renderer.version, '19.2.0-canary'); if (enableSuspenseTab) { this._bridge.send('enableSuspenseTab'); } diff --git a/packages/react-devtools-shared/src/devtools/cache.js b/packages/react-devtools-shared/src/devtools/cache.js index 5ed2133b06898..c927b4d3dbf36 100644 --- a/packages/react-devtools-shared/src/devtools/cache.js +++ b/packages/react-devtools-shared/src/devtools/cache.js @@ -42,6 +42,7 @@ export type Resource = { let readContext; if (typeof React.use === 'function') { readContext = function (Context: ReactContext) { + // eslint-disable-next-line react-hooks-published/rules-of-hooks return React.use(Context); }; } else if ( @@ -141,6 +142,7 @@ export function createResource( const key = hashInput(input); const result: Thenable = accessResult(resource, fetch, input, key); if (typeof React.use === 'function') { + // eslint-disable-next-line react-hooks-published/rules-of-hooks return React.use(result); } diff --git a/packages/react-devtools-shared/src/devtools/views/ErrorBoundary/cache.js b/packages/react-devtools-shared/src/devtools/views/ErrorBoundary/cache.js index 6da066a110338..6ae471ec993c4 100644 --- a/packages/react-devtools-shared/src/devtools/views/ErrorBoundary/cache.js +++ b/packages/react-devtools-shared/src/devtools/views/ErrorBoundary/cache.js @@ -23,6 +23,7 @@ const API_TIMEOUT = 3000; function readRecord(record: Thenable): T | null { if (typeof React.use === 'function') { try { + // eslint-disable-next-line react-hooks-published/rules-of-hooks return React.use(record); } catch (x) { if (x === null) { diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.js b/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.js index 77f0d13feb72f..8b55e0f8c0c09 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.js @@ -8,6 +8,7 @@ */ import type {SchedulingEvent} from 'react-devtools-timeline/src/types'; +import type {ReactFunctionLocation} from 'shared/ReactTypes'; import * as React from 'react'; import Button from '../Button'; @@ -27,6 +28,28 @@ import styles from './SidebarEventInfo.css'; export type Props = {}; +type FunctionLocationProps = { + location: ReactFunctionLocation, + displayName: string, +}; +function FunctionLocation({location, displayName}: FunctionLocationProps) { + // TODO: We should support symbolication here as well, but + // symbolicating the whole stack can be expensive + const [canViewSource, viewSource] = useOpenResource(location, null); + return ( +
  • + +
  • + ); +} + type SchedulingEventProps = { eventInfo: SchedulingEvent, }; @@ -74,25 +97,12 @@ function SchedulingEventInfo({eventInfo}: SchedulingEventProps) { ); } - // TODO: We should support symbolication here as well, but - // symbolicating the whole stack can be expensive - const [canViewSource, viewSource] = useOpenResource( - location, - null, - ); return ( -
  • - -
  • + ); }, )} diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js index d67cc9a9fe3da..00ca3e1459476 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js @@ -98,6 +98,18 @@ function SuspenseRects({ }); } + function handleDoubleClick(event: SyntheticMouseEvent) { + if (event.defaultPrevented) { + // Already clicked on an inner rect + return; + } + event.preventDefault(); + suspenseTreeDispatch({ + type: 'TOGGLE_TIMELINE_FOR_ID', + payload: suspenseID, + }); + } + function handlePointerOver(event: SyntheticPointerEvent) { if (event.defaultPrevented) { // Already hovered an inner rect @@ -105,6 +117,10 @@ function SuspenseRects({ } event.preventDefault(); highlightHostInstance(suspenseID); + suspenseTreeDispatch({ + type: 'HOVER_TIMELINE_FOR_ID', + payload: suspenseID, + }); } function handlePointerLeave(event: SyntheticPointerEvent) { @@ -114,6 +130,10 @@ function SuspenseRects({ } event.preventDefault(); clearHighlightHostInstance(); + suspenseTreeDispatch({ + type: 'HOVER_TIMELINE_FOR_ID', + payload: -1, + }); } // TODO: Use the nearest Suspense boundary @@ -137,6 +157,7 @@ function SuspenseRects({ rect={rect} data-highlighted={selected} onClick={handleClick} + onDoubleClick={handleDoubleClick} onPointerOver={handlePointerOver} onPointerLeave={handlePointerLeave} // Reach-UI tooltip will go out of bounds of parent scroll container. diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.css index 94e51ef63d330..4668ede127dd6 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.css +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.css @@ -51,6 +51,8 @@ background: var(--color-background-selected); } +.SuspenseScrubberStepHighlight > .SuspenseScrubberBead, +.SuspenseScrubberStepHighlight > .SuspenseScrubberBeadSelected, .SuspenseScrubberStep:hover > .SuspenseScrubberBead, .SuspenseScrubberStep:hover > .SuspenseScrubberBeadSelected { height: 0.75rem; diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js index f1f96a33e00e2..cbb76e4164708 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js @@ -18,6 +18,7 @@ export default function SuspenseScrubber({ min, max, value, + highlight, onBlur, onChange, onFocus, @@ -27,6 +28,7 @@ export default function SuspenseScrubber({ min: number, max: number, value: number, + highlight: number, onBlur: () => void, onChange: (index: number) => void, onFocus: () => void, @@ -53,7 +55,12 @@ export default function SuspenseScrubber({ steps.push(
    0 ? timeline.length - 1 : 0; - if (rootID === null) { - return ( -
    No root selected.
    - ); - } - - if (!store.supportsTogglingSuspense(rootID)) { - return ( -
    - Can't step through Suspense in production apps. -
    - ); - } - - if (timeline.length === 0) { - return ( -
    - Root contains no Suspense nodes. -
    - ); - } - function switchSuspenseNode(nextTimelineIndex: number) { const nextSelectedSuspenseID = timeline[nextTimelineIndex]; highlightHostInstance(nextSelectedSuspenseID); @@ -175,6 +154,28 @@ function SuspenseTimelineInput() { }; }, [playing]); + if (rootID === null) { + return ( +
    No root selected.
    + ); + } + + if (!store.supportsTogglingSuspense(rootID)) { + return ( +
    + Can't step through Suspense in production apps. +
    + ); + } + + if (timeline.length === 0) { + return ( +
    + Root contains no Suspense nodes. +
    + ); + } + return ( <>