[playground] ViewTransition on config expand (#34595)

<!--
  Thanks for submitting a pull request!
We appreciate you spending the time to work on these changes. Please
provide enough information so that others can review your pull request.
The three fields below are mandatory.

Before submitting a pull request, please make sure the following is
done:

1. Fork [the repository](https://github.com/facebook/react) and create
your branch from `main`.
  2. Run `yarn` in the repository root.
3. If you've fixed a bug or added code that should be tested, add tests!
4. Ensure the test suite passes (`yarn test`). Tip: `yarn test --watch
TestName` is helpful in development.
5. Run `yarn test --prod` to test in the production environment. It
supports the same options as `yarn test`.
6. If you need a debugger, run `yarn test --debug --watch TestName`,
open `chrome://inspect`, and press "Inspect".
7. Format your code with
[prettier](https://github.com/prettier/prettier) (`yarn prettier`).
8. Make sure your code lints (`yarn lint`). Tip: `yarn linc` to only
check changed files.
  9. Run the [Flow](https://flowtype.org/) type checks (`yarn flow`).
  10. If you haven't already, complete the CLA.

Learn more about contributing:
https://reactjs.org/docs/how-to-contribute.html
-->

## Summary

<!--
Explain the **motivation** for making this change. What existing problem
does the pull request solve?
-->

Introduced `<ViewTransition>` to the React Compiler Playground. Added an
initial animation on the config panel opening/closing to allow for a
smoother visual experience. Previously, the panel would flash in and out
of the screen upon open/close.

## How did you test this change?

<!--
Demonstrate the code is solid. Example: The exact commands you ran and
their output, screenshots / videos if the pull request changes the user
interface.
How exactly did you verify that your PR solves the issue you wanted to
solve?
  If you leave this empty, your PR will very likely be closed.
-->




https://github.com/user-attachments/assets/9dc77a6b-d4a5-4a7a-9d81-007ebb55e8d2
This commit is contained in:
Eugene Choi 2025-09-29 14:09:37 -04:00 committed by GitHub
parent d15d7fd79e
commit 319a7867d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 238 additions and 137 deletions

View File

@ -23,7 +23,8 @@ function formatPrint(data: Array<string>): Promise<string> {
async function expandConfigs(page: Page): Promise<void> { async function expandConfigs(page: Page): Promise<void> {
const expandButton = page.locator('[title="Expand config editor"]'); 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 }) { const TEST_SOURCE = `export default function TestComponent({ x }) {

View File

@ -18,19 +18,21 @@ export default function AccordionWindow(props: {
changedPasses: Set<string>; changedPasses: Set<string>;
}): React.ReactElement { }): React.ReactElement {
return ( return (
<div className="flex flex-row h-full"> <div className="flex-1 min-w-[550px] sm:min-w-0">
{Array.from(props.tabs.keys()).map(name => { <div className="flex flex-row h-full">
return ( {Array.from(props.tabs.keys()).map(name => {
<AccordionWindowItem return (
name={name} <AccordionWindowItem
key={name} name={name}
tabs={props.tabs} key={name}
tabsOpen={props.tabsOpen} tabs={props.tabs}
setTabsOpen={props.setTabsOpen} tabsOpen={props.tabsOpen}
hasChanged={props.changedPasses.has(name)} setTabsOpen={props.setTabsOpen}
/> hasChanged={props.changedPasses.has(name)}
); />
})} );
})}
</div>
</div> </div>
); );
} }

View File

@ -9,12 +9,19 @@ import MonacoEditor, {loader, type Monaco} from '@monaco-editor/react';
import {PluginOptions} from 'babel-plugin-react-compiler'; import {PluginOptions} from 'babel-plugin-react-compiler';
import type {editor} from 'monaco-editor'; import type {editor} from 'monaco-editor';
import * as monaco 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 {Resizable} from 're-resizable';
import {useStore, useStoreDispatch} from '../StoreContext'; import {useStore, useStoreDispatch} from '../StoreContext';
import {monacoOptions} from './monacoOptions'; import {monacoOptions} from './monacoOptions';
import {IconChevron} from '../Icons/IconChevron'; import {IconChevron} from '../Icons/IconChevron';
import prettyFormat from 'pretty-format'; 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 // @ts-expect-error - webpack asset/source loader handles .d.ts files as strings
import compilerTypeDefs from 'babel-plugin-react-compiler/dist/index.d.ts'; import compilerTypeDefs from 'babel-plugin-react-compiler/dist/index.d.ts';
@ -36,7 +43,12 @@ export default function ConfigEditor({
display: isExpanded ? 'block' : 'none', display: isExpanded ? 'block' : 'none',
}}> }}>
<ExpandedEditor <ExpandedEditor
onToggle={setIsExpanded} onToggle={() => {
startTransition(() => {
addTransitionType(CONFIG_PANEL_TRANSITION);
setIsExpanded(false);
});
}}
appliedOptions={appliedOptions} appliedOptions={appliedOptions}
/> />
</div> </div>
@ -44,7 +56,14 @@ export default function ConfigEditor({
style={{ style={{
display: !isExpanded ? 'block' : 'none', display: !isExpanded ? 'block' : 'none',
}}> }}>
<CollapsedEditor onToggle={setIsExpanded} /> <CollapsedEditor
onToggle={() => {
startTransition(() => {
addTransitionType(CONFIG_PANEL_TRANSITION);
setIsExpanded(true);
});
}}
/>
</div> </div>
</> </>
); );
@ -54,7 +73,7 @@ function ExpandedEditor({
onToggle, onToggle,
appliedOptions, appliedOptions,
}: { }: {
onToggle: (expanded: boolean) => void; onToggle: () => void;
appliedOptions: PluginOptions | null; appliedOptions: PluginOptions | null;
}): React.ReactElement { }): React.ReactElement {
const store = useStore(); const store = useStore();
@ -111,90 +130,93 @@ function ExpandedEditor({
: 'Invalid configs'; : 'Invalid configs';
return ( return (
<Resizable <ViewTransition
minWidth={300} update={{[CONFIG_PANEL_TRANSITION]: 'slide-in', default: 'none'}}>
maxWidth={600} <Resizable
defaultSize={{width: 350}} minWidth={300}
enable={{right: true, bottom: false}}> maxWidth={600}
<div className="bg-blue-10 relative h-full flex flex-col !h-[calc(100vh_-_3.5rem)] border border-gray-300"> defaultSize={{width: 350}}
<div enable={{right: true, bottom: false}}>
className="absolute w-8 h-16 bg-blue-10 rounded-r-full flex items-center justify-center z-[2] cursor-pointer border border-l-0 border-gray-300" <div className="bg-blue-10 relative h-full flex flex-col !h-[calc(100vh_-_3.5rem)] border border-gray-300">
title="Minimize config editor" <div
onClick={() => onToggle(false)} className="absolute w-8 h-16 bg-blue-10 rounded-r-full flex items-center justify-center z-[2] cursor-pointer border border-l-0 border-gray-300"
style={{ title="Minimize config editor"
top: '50%', onClick={onToggle}
marginTop: '-32px', style={{
right: '-32px', top: '50%',
borderTopLeftRadius: 0, marginTop: '-32px',
borderBottomLeftRadius: 0, right: '-32px',
}}> borderTopLeftRadius: 0,
<IconChevron displayDirection="left" className="text-blue-50" /> borderBottomLeftRadius: 0,
</div> }}>
<IconChevron displayDirection="left" className="text-blue-50" />
</div>
<div className="flex-1 flex flex-col m-2 mb-2"> <div className="flex-1 flex flex-col m-2 mb-2">
<div className="pb-2"> <div className="pb-2">
<h2 className="inline-block text-blue-50 py-1.5 px-1.5 xs:px-3 sm:px-4 text-sm"> <h2 className="inline-block text-blue-50 py-1.5 px-1.5 xs:px-3 sm:px-4 text-sm">
Config Overrides Config Overrides
</h2> </h2>
</div>
<div className="flex-1 rounded-lg overflow-hidden border border-gray-300">
<MonacoEditor
path={'config.ts'}
language={'typescript'}
value={store.config}
onMount={handleMount}
onChange={handleChange}
loading={''}
className="monaco-editor-config"
options={{
...monacoOptions,
lineNumbers: 'off',
renderLineHighlight: 'none',
overviewRulerBorder: false,
overviewRulerLanes: 0,
fontSize: 12,
scrollBeyondLastLine: false,
glyphMargin: false,
}}
/>
</div>
</div> </div>
<div className="flex-1 rounded-lg overflow-hidden border border-gray-300"> <div className="flex-1 flex flex-col m-2">
<MonacoEditor <div className="pb-2">
path={'config.ts'} <h2 className="inline-block text-blue-50 py-1.5 px-1.5 xs:px-3 sm:px-4 text-sm">
language={'typescript'} Applied Configs
value={store.config} </h2>
onMount={handleMount} </div>
onChange={handleChange} <div className="flex-1 rounded-lg overflow-hidden border border-gray-300">
loading={''} <MonacoEditor
className="monaco-editor-config" path={'applied-config.js'}
options={{ language={'javascript'}
...monacoOptions, value={formattedAppliedOptions}
lineNumbers: 'off', loading={''}
renderLineHighlight: 'none', className="monaco-editor-applied-config"
overviewRulerBorder: false, options={{
overviewRulerLanes: 0, ...monacoOptions,
fontSize: 12, lineNumbers: 'off',
scrollBeyondLastLine: false, renderLineHighlight: 'none',
glyphMargin: false, overviewRulerBorder: false,
}} overviewRulerLanes: 0,
/> fontSize: 12,
scrollBeyondLastLine: false,
readOnly: true,
glyphMargin: false,
}}
/>
</div>
</div> </div>
</div> </div>
<div className="flex-1 flex flex-col m-2"> </Resizable>
<div className="pb-2"> </ViewTransition>
<h2 className="inline-block text-blue-50 py-1.5 px-1.5 xs:px-3 sm:px-4 text-sm">
Applied Configs
</h2>
</div>
<div className="flex-1 rounded-lg overflow-hidden border border-gray-300">
<MonacoEditor
path={'applied-config.js'}
language={'javascript'}
value={formattedAppliedOptions}
loading={''}
className="monaco-editor-applied-config"
options={{
...monacoOptions,
lineNumbers: 'off',
renderLineHighlight: 'none',
overviewRulerBorder: false,
overviewRulerLanes: 0,
fontSize: 12,
scrollBeyondLastLine: false,
readOnly: true,
glyphMargin: false,
}}
/>
</div>
</div>
</div>
</Resizable>
); );
} }
function CollapsedEditor({ function CollapsedEditor({
onToggle, onToggle,
}: { }: {
onToggle: (expanded: boolean) => void; onToggle: () => void;
}): React.ReactElement { }): React.ReactElement {
return ( return (
<div <div
@ -203,7 +225,7 @@ function CollapsedEditor({
<div <div
className="absolute w-10 h-16 bg-blue-10 hover:translate-x-2 transition-transform rounded-r-full flex items-center justify-center z-[2] cursor-pointer border border-gray-300" className="absolute w-10 h-16 bg-blue-10 hover:translate-x-2 transition-transform rounded-r-full flex items-center justify-center z-[2] cursor-pointer border border-gray-300"
title="Expand config editor" title="Expand config editor"
onClick={() => onToggle(true)} onClick={onToggle}
style={{ style={{
top: '50%', top: '50%',
marginTop: '-32px', marginTop: '-32px',

View File

@ -343,12 +343,8 @@ export default function Editor(): JSX.Element {
<ConfigEditor appliedOptions={appliedOptions} /> <ConfigEditor appliedOptions={appliedOptions} />
</div> </div>
<div className="flex flex-1 min-w-0"> <div className="flex flex-1 min-w-0">
<div className="flex-1 min-w-[550px] sm:min-w-0"> <Input language={language} errors={errors} />
<Input language={language} errors={errors} /> <Output store={deferredStore} compilerOutput={mergedOutput} />
</div>
<div className="flex-1 min-w-[550px] sm:min-w-0">
<Output store={deferredStore} compilerOutput={mergedOutput} />
</div>
</div> </div>
</div> </div>
</> </>

View File

@ -13,11 +13,17 @@ import {
import invariant from 'invariant'; import invariant from 'invariant';
import type {editor} from 'monaco-editor'; import type {editor} from 'monaco-editor';
import * as monaco 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 {renderReactCompilerMarkers} from '../../lib/reactCompilerMonacoDiagnostics';
import {useStore, useStoreDispatch} from '../StoreContext'; import {useStore, useStoreDispatch} from '../StoreContext';
import TabbedWindow from '../TabbedWindow'; import TabbedWindow from '../TabbedWindow';
import {monacoOptions} from './monacoOptions'; 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. // @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'; 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'); const [activeTab, setActiveTab] = useState('Input');
return ( return (
<div className="relative flex flex-col flex-none border-r border-gray-200"> <ViewTransition
<div className="!h-[calc(100vh_-_3.5rem)]"> update={{
<div className="flex flex-col h-full"> [CONFIG_PANEL_TRANSITION]: 'container',
default: 'none',
}}>
<div className="flex-1 min-w-[550px] sm:min-w-0">
<div className="flex flex-col h-full !h-[calc(100vh_-_3.5rem)] border-r border-gray-200">
<TabbedWindow <TabbedWindow
tabs={tabs} tabs={tabs}
activeTab={activeTab} activeTab={activeTab}
@ -165,6 +175,6 @@ export default function Input({errors, language}: Props): JSX.Element {
/> />
</div> </div>
</div> </div>
</div> </ViewTransition>
); );
} }

View File

@ -20,11 +20,19 @@ import parserBabel from 'prettier/plugins/babel';
import * as prettierPluginEstree from 'prettier/plugins/estree'; import * as prettierPluginEstree from 'prettier/plugins/estree';
import * as prettier from 'prettier/standalone'; import * as prettier from 'prettier/standalone';
import {type Store} from '../../lib/stores'; 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 AccordionWindow from '../AccordionWindow';
import TabbedWindow from '../TabbedWindow'; import TabbedWindow from '../TabbedWindow';
import {monacoOptions} from './monacoOptions'; import {monacoOptions} from './monacoOptions';
import {BabelFileResult} from '@babel/core'; import {BabelFileResult} from '@babel/core';
import {CONFIG_PANEL_TRANSITION} from '../../lib/transitionTypes';
import {LRUCache} from 'lru-cache'; import {LRUCache} from 'lru-cache';
const MemoizedOutput = memo(Output); const MemoizedOutput = memo(Output);
@ -280,22 +288,34 @@ function OutputContent({store, compilerOutput}: Props): JSX.Element {
if (!store.showInternals) { if (!store.showInternals) {
return ( return (
<TabbedWindow <ViewTransition
tabs={tabs} update={{
activeTab={activeTab} [CONFIG_PANEL_TRANSITION]: 'container',
onTabChange={setActiveTab} default: 'none',
/> }}>
<TabbedWindow
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
</ViewTransition>
); );
} }
return ( return (
<AccordionWindow <ViewTransition
defaultTab={store.showInternals ? 'HIR' : 'Output'} update={{
setTabsOpen={setTabsOpen} [CONFIG_PANEL_TRANSITION]: 'accordion-container',
tabsOpen={tabsOpen} default: 'none',
tabs={tabs} }}>
changedPasses={changedPasses} <AccordionWindow
/> defaultTab={store.showInternals ? 'HIR' : 'Output'}
setTabsOpen={setTabsOpen}
tabsOpen={tabsOpen}
tabs={tabs}
changedPasses={changedPasses}
/>
</ViewTransition>
); );
} }

View File

@ -17,26 +17,28 @@ export default function TabbedWindow({
onTabChange: (tab: string) => void; onTabChange: (tab: string) => void;
}): React.ReactElement { }): React.ReactElement {
return ( return (
<div className="flex flex-col h-full max-w-full"> <div className="flex-1 min-w-[550px] sm:min-w-0">
<div className="flex p-2 flex-shrink-0"> <div className="flex flex-col h-full max-w-full">
{Array.from(tabs.keys()).map(tab => { <div className="flex p-2 flex-shrink-0">
const isActive = activeTab === tab; {Array.from(tabs.keys()).map(tab => {
return ( const isActive = activeTab === tab;
<button return (
key={tab} <button
onClick={() => onTabChange(tab)} key={tab}
className={clsx( onClick={() => onTabChange(tab)}
'active:scale-95 transition-transform py-1.5 px-1.5 xs:px-3 sm:px-4 rounded-full text-sm', className={clsx(
!isActive && 'hover:bg-primary/5', 'active:scale-95 transition-transform py-1.5 px-1.5 xs:px-3 sm:px-4 rounded-full text-sm',
isActive && 'bg-highlight text-link', !isActive && 'hover:bg-primary/5',
)}> isActive && 'bg-highlight text-link',
{tab} )}>
</button> {tab}
); </button>
})} );
</div> })}
<div className="flex-1 overflow-hidden w-full h-full"> </div>
{tabs.get(activeTab)} <div className="flex-1 overflow-hidden w-full h-full">
{tabs.get(activeTab)}
</div>
</div> </div>
</div> </div>
); );

View File

@ -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';

View File

@ -11,6 +11,7 @@ const path = require('path');
const nextConfig = { const nextConfig = {
experimental: { experimental: {
reactCompiler: true, reactCompiler: true,
viewTransition: true,
}, },
reactStrictMode: true, reactStrictMode: true,
webpack: (config, options) => { webpack: (config, options) => {

View File

@ -69,3 +69,42 @@
scrollbar-width: none; /* Firefox */ 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;
}