From 1a27af36073d7dffcc7a4284ed569af6f804747a Mon Sep 17 00:00:00 2001 From: Eugene Choi <4eugenechoi@gmail.com> Date: Fri, 12 Sep 2025 11:43:04 -0400 Subject: [PATCH] [playground] Update the playground UI (#34468) ## Summary Updated the UI of the React compiler playground. The config, Input, and Output panels will now span the viewport width when "Show Internals" is not toggled on. When "Show Internals" is toggled on, the old vertical accordion tabs are still used. Going to add support for the "Applied Configs" tabs underneath the "Config Overrides" tab next. ## How did you test this change? https://github.com/user-attachments/assets/b8eab028-f58c-4cb9-a8b2-0f098f2cc262 --- .../compilationMode-all-output.txt | 3 +- .../playground/components/AccordionWindow.tsx | 106 +++++++ .../components/Editor/ConfigEditor.tsx | 147 ++++++---- .../components/Editor/EditorImpl.tsx | 267 ++++++++++-------- .../playground/components/Editor/Input.tsx | 69 +++-- .../playground/components/Editor/Output.tsx | 30 +- .../apps/playground/components/Header.tsx | 2 +- .../components/Icons/IconChevron.tsx | 41 +++ .../playground/components/TabbedWindow.tsx | 122 +++----- compiler/apps/playground/playwright.config.js | 6 +- 10 files changed, 498 insertions(+), 295 deletions(-) create mode 100644 compiler/apps/playground/components/AccordionWindow.tsx create mode 100644 compiler/apps/playground/components/Icons/IconChevron.tsx diff --git a/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/compilationMode-all-output.txt b/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/compilationMode-all-output.txt index 0084911eec..ab7b3ce58c 100644 --- a/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/compilationMode-all-output.txt +++ b/compiler/apps/playground/__tests__/e2e/__snapshots__/page.spec.ts/compilationMode-all-output.txt @@ -1,5 +1,4 @@ -import { c as _c } from "react/compiler-runtime"; //  -@compilationMode:"all" +import { c as _c } from "react/compiler-runtime"; // @compilationMode:"all" function nonReactFn() {   const $ = _c(1);   let t0; diff --git a/compiler/apps/playground/components/AccordionWindow.tsx b/compiler/apps/playground/components/AccordionWindow.tsx new file mode 100644 index 0000000000..de3b01b0b0 --- /dev/null +++ b/compiler/apps/playground/components/AccordionWindow.tsx @@ -0,0 +1,106 @@ +/** + * 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. + */ + +import {Resizable} from 're-resizable'; +import React, {useCallback} from 'react'; + +type TabsRecord = Map; + +export default function AccordionWindow(props: { + defaultTab: string | null; + tabs: TabsRecord; + tabsOpen: Set; + setTabsOpen: (newTab: Set) => void; + changedPasses: Set; +}): React.ReactElement { + if (props.tabs.size === 0) { + return ( +
+ No compiler output detected, see errors below +
+ ); + } + return ( +
+ {Array.from(props.tabs.keys()).map(name => { + return ( + + ); + })} +
+ ); +} + +function AccordionWindowItem({ + name, + tabs, + tabsOpen, + setTabsOpen, + hasChanged, +}: { + name: string; + tabs: TabsRecord; + tabsOpen: Set; + setTabsOpen: (newTab: Set) => void; + hasChanged: boolean; +}): React.ReactElement { + const isShow = tabsOpen.has(name); + + const toggleTabs = useCallback(() => { + const nextState = new Set(tabsOpen); + if (nextState.has(name)) { + nextState.delete(name); + } else { + nextState.add(name); + } + setTabsOpen(nextState); + }, [tabsOpen, name, setTabsOpen]); + + // Replace spaces with non-breaking spaces + const displayName = name.replace(/ /g, '\u00A0'); + + return ( +
+ {isShow ? ( + +

+ - {displayName} +

+ {tabs.get(name) ??
No output for {name}
} +
+ ) : ( +
+ +
+ )} +
+ ); +} diff --git a/compiler/apps/playground/components/Editor/ConfigEditor.tsx b/compiler/apps/playground/components/Editor/ConfigEditor.tsx index 63522987db..5f904960ba 100644 --- a/compiler/apps/playground/components/Editor/ConfigEditor.tsx +++ b/compiler/apps/playground/components/Editor/ConfigEditor.tsx @@ -8,10 +8,11 @@ import MonacoEditor, {loader, type Monaco} from '@monaco-editor/react'; import type {editor} from 'monaco-editor'; import * as monaco from 'monaco-editor'; -import React, {useState, useCallback} from 'react'; +import React, {useState} from 'react'; import {Resizable} from 're-resizable'; import {useStore, useStoreDispatch} from '../StoreContext'; import {monacoOptions} from './monacoOptions'; +import {IconChevron} from '../Icons/IconChevron'; // @ts-expect-error - webpack asset/source loader handles .d.ts files as strings import compilerTypeDefs from 'babel-plugin-react-compiler/dist/index.d.ts'; @@ -20,13 +21,26 @@ loader.config({monaco}); export default function ConfigEditor(): React.ReactElement { const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+ {isExpanded ? ( + + ) : ( + + )} +
+ ); +} + +function ExpandedEditor({ + onToggle, +}: { + onToggle: (expanded: boolean) => void; +}): React.ReactElement { const store = useStore(); const dispatchStore = useStoreDispatch(); - const toggleExpanded = useCallback(() => { - setIsExpanded(prev => !prev); - }, []); - const handleChange: (value: string | undefined) => void = value => { if (value === undefined) return; @@ -68,57 +82,82 @@ export default function ConfigEditor(): React.ReactElement { }; return ( -
- {isExpanded ? ( - -

- - Config Overrides -

-
- -
-
- ) : ( -
- +
- )} +
onToggle(false)} + style={{ + top: '50%', + marginTop: '-32px', + right: '-32px', + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + }}> + +
+
+ +
+
+ + ); +} + +function CollapsedEditor({ + onToggle, +}: { + onToggle: (expanded: boolean) => void; +}): React.ReactElement { + return ( +
+
onToggle(true)} + style={{ + top: '50%', + marginTop: '-32px', + left: '-8px', + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + }}> + +
); } diff --git a/compiler/apps/playground/components/Editor/EditorImpl.tsx b/compiler/apps/playground/components/Editor/EditorImpl.tsx index d6a2bccc8e..a90447c96b 100644 --- a/compiler/apps/playground/components/Editor/EditorImpl.tsx +++ b/compiler/apps/playground/components/Editor/EditorImpl.tsx @@ -24,7 +24,6 @@ import BabelPluginReactCompiler, { printFunctionWithOutlined, type LoggerEvent, } from 'babel-plugin-react-compiler'; -import clsx from 'clsx'; import invariant from 'invariant'; import {useSnackbar} from 'notistack'; import {useDeferredValue, useMemo} from 'react'; @@ -47,7 +46,6 @@ import { PrintedCompilerPipelineValue, } from './Output'; import {transformFromAstSync} from '@babel/core'; -import {useSearchParams} from 'next/navigation'; function parseInput( input: string, @@ -144,6 +142,61 @@ const COMMON_HOOKS: Array<[string, Hook]> = [ ], ]; +function parseOptions( + source: string, + mode: 'compiler' | 'linter', + configOverrides: string, +): PluginOptions { + // Extract the first line to quickly check for custom test directives + const pragma = source.substring(0, source.indexOf('\n')); + + const parsedPragmaOptions = parseConfigPragmaForTests(pragma, { + compilationMode: 'infer', + environment: + mode === 'linter' + ? { + // enabled in compiler + validateRefAccessDuringRender: false, + // enabled in linter + validateNoSetStateInRender: true, + validateNoSetStateInEffects: true, + validateNoJSXInTryStatements: true, + validateNoImpureFunctionsInRender: true, + validateStaticComponents: true, + validateNoFreezingKnownMutableFunctions: true, + validateNoVoidUseMemo: true, + } + : { + /* use defaults for compiler mode */ + }, + }); + + // Parse config overrides from config editor + let configOverrideOptions: any = {}; + const configMatch = configOverrides.match(/^\s*import.*?\n\n\((.*)\)/s); + // TODO: initialize store with URL params, not empty store + if (configOverrides.trim()) { + if (configMatch && configMatch[1]) { + const configString = configMatch[1].replace(/satisfies.*$/, '').trim(); + configOverrideOptions = new Function(`return (${configString})`)(); + } else { + throw new Error('Invalid override format'); + } + } + + const opts: PluginOptions = parsePluginOptions({ + ...parsedPragmaOptions, + ...configOverrideOptions, + environment: { + ...parsedPragmaOptions.environment, + ...configOverrideOptions.environment, + customHooks: new Map([...COMMON_HOOKS]), + }, + }); + + return opts; +} + function compile( source: string, mode: 'compiler' | 'linter', @@ -167,120 +220,94 @@ function compile( language = 'typescript'; } let transformOutput; + + let baseOpts: PluginOptions | null = null; try { - // Extract the first line to quickly check for custom test directives - const pragma = source.substring(0, source.indexOf('\n')); - const logIR = (result: CompilerPipelineValue): void => { - switch (result.kind) { - case 'ast': { - break; - } - case 'hir': { - upsert({ - kind: 'hir', - fnName: result.value.id, - name: result.name, - value: printFunctionWithOutlined(result.value), - }); - break; - } - case 'reactive': { - upsert({ - kind: 'reactive', - fnName: result.value.id, - name: result.name, - value: printReactiveFunctionWithOutlined(result.value), - }); - break; - } - case 'debug': { - upsert({ - kind: 'debug', - fnName: null, - name: result.name, - value: result.value, - }); - break; - } - default: { - const _: never = result; - throw new Error(`Unhandled result ${result}`); - } - } - }; - const parsedPragmaOptions = parseConfigPragmaForTests(pragma, { - compilationMode: 'infer', - environment: - mode === 'linter' - ? { - // enabled in compiler - validateRefAccessDuringRender: false, - // enabled in linter - validateNoSetStateInRender: true, - validateNoSetStateInEffects: true, - validateNoJSXInTryStatements: true, - validateNoImpureFunctionsInRender: true, - validateStaticComponents: true, - validateNoFreezingKnownMutableFunctions: true, - validateNoVoidUseMemo: true, - } - : { - /* use defaults for compiler mode */ - }, - }); - - // Parse config overrides from config editor - let configOverrideOptions: any = {}; - const configMatch = configOverrides.match(/^\s*import.*?\n\n\((.*)\)/s); - // TODO: initialize store with URL params, not empty store - if (configOverrides.trim()) { - if (configMatch && configMatch[1]) { - const configString = configMatch[1].replace(/satisfies.*$/, '').trim(); - configOverrideOptions = new Function(`return (${configString})`)(); - } else { - throw new Error('Invalid config overrides'); - } - } - - const opts: PluginOptions = parsePluginOptions({ - ...parsedPragmaOptions, - ...configOverrideOptions, - environment: { - ...parsedPragmaOptions.environment, - ...configOverrideOptions.environment, - customHooks: new Map([...COMMON_HOOKS]), - }, - logger: { - debugLogIRs: logIR, - logEvent: (_filename: string | null, event: LoggerEvent) => { - if (event.kind === 'CompileError') { - otherErrors.push(event.detail); - } - }, - }, - }); - transformOutput = invokeCompiler(source, language, opts); + baseOpts = parseOptions(source, mode, configOverrides); } catch (err) { - /** - * error might be an invariant violation or other runtime error - * (i.e. object shape that is not CompilerError) - */ - if (err instanceof CompilerError && err.details.length > 0) { - error.merge(err); - } else { + error.details.push( + new CompilerErrorDetail({ + category: ErrorCategory.Config, + reason: `Unexpected failure when transforming configs! \n${err}`, + loc: null, + suggestions: null, + }), + ); + } + if (baseOpts) { + try { + const logIR = (result: CompilerPipelineValue): void => { + switch (result.kind) { + case 'ast': { + break; + } + case 'hir': { + upsert({ + kind: 'hir', + fnName: result.value.id, + name: result.name, + value: printFunctionWithOutlined(result.value), + }); + break; + } + case 'reactive': { + upsert({ + kind: 'reactive', + fnName: result.value.id, + name: result.name, + value: printReactiveFunctionWithOutlined(result.value), + }); + break; + } + case 'debug': { + upsert({ + kind: 'debug', + fnName: null, + name: result.name, + value: result.value, + }); + break; + } + default: { + const _: never = result; + throw new Error(`Unhandled result ${result}`); + } + } + }; + // Add logger options to the parsed options + const opts = { + ...baseOpts, + logger: { + debugLogIRs: logIR, + logEvent: (_filename: string | null, event: LoggerEvent) => { + if (event.kind === 'CompileError') { + otherErrors.push(event.detail); + } + }, + }, + }; + transformOutput = invokeCompiler(source, language, opts); + } catch (err) { /** - * Handle unexpected failures by logging (to get a stack trace) - * and reporting + * error might be an invariant violation or other runtime error + * (i.e. object shape that is not CompilerError) */ - console.error(err); - error.details.push( - new CompilerErrorDetail({ - category: ErrorCategory.Invariant, - reason: `Unexpected failure when transforming input! ${err}`, - loc: null, - suggestions: null, - }), - ); + if (err instanceof CompilerError && err.details.length > 0) { + error.merge(err); + } else { + /** + * Handle unexpected failures by logging (to get a stack trace) + * and reporting + */ + error.details.push( + new CompilerErrorDetail({ + category: ErrorCategory.Invariant, + reason: `Unexpected failure when transforming input! \n${err}`, + loc: null, + suggestions: null, + }), + ); + } } } // Only include logger errors if there weren't other errors @@ -350,13 +377,17 @@ export default function Editor(): JSX.Element { } return ( <> -
- -
- +
+
+
-
- +
+
+ +
+
+ +
diff --git a/compiler/apps/playground/components/Editor/Input.tsx b/compiler/apps/playground/components/Editor/Input.tsx index f4c64a14a0..206b98300b 100644 --- a/compiler/apps/playground/components/Editor/Input.tsx +++ b/compiler/apps/playground/components/Editor/Input.tsx @@ -6,7 +6,10 @@ */ import MonacoEditor, {loader, type Monaco} from '@monaco-editor/react'; -import {CompilerErrorDetail} from 'babel-plugin-react-compiler'; +import { + CompilerErrorDetail, + CompilerDiagnostic, +} from 'babel-plugin-react-compiler'; import invariant from 'invariant'; import type {editor} from 'monaco-editor'; import * as monaco from 'monaco-editor'; @@ -14,6 +17,7 @@ import {Resizable} from 're-resizable'; import {useEffect, useState} from 'react'; import {renderReactCompilerMarkers} from '../../lib/reactCompilerMonacoDiagnostics'; import {useStore, useStoreDispatch} from '../StoreContext'; +import TabbedWindow from '../TabbedWindow'; import {monacoOptions} from './monacoOptions'; // @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'; @@ -21,7 +25,7 @@ import React$Types from '../../node_modules/@types/react/index.d.ts'; loader.config({monaco}); type Props = { - errors: Array; + errors: Array; language: 'flow' | 'typescript'; }; @@ -135,30 +139,51 @@ export default function Input({errors, language}: Props): JSX.Element { }); }; + const editorContent = ( + + ); + + const tabs = new Map([['Input', editorContent]]); + const [activeTab, setActiveTab] = useState('Input'); + + const tabbedContent = ( +
+ +
+ ); + return (
- - - + className="!h-[calc(100vh_-_3.5rem)]"> + {tabbedContent} + + ) : ( +
{tabbedContent}
+ )}
); } diff --git a/compiler/apps/playground/components/Editor/Output.tsx b/compiler/apps/playground/components/Editor/Output.tsx index ae8154f589..22f908e51b 100644 --- a/compiler/apps/playground/components/Editor/Output.tsx +++ b/compiler/apps/playground/components/Editor/Output.tsx @@ -21,13 +21,17 @@ import * as prettierPluginEstree from 'prettier/plugins/estree'; import * as prettier from 'prettier/standalone'; import {memo, ReactNode, useEffect, useState} from 'react'; import {type Store} from '../../lib/stores'; +import AccordionWindow from '../AccordionWindow'; import TabbedWindow from '../TabbedWindow'; import {monacoOptions} from './monacoOptions'; import {BabelFileResult} from '@babel/core'; + const MemoizedOutput = memo(Output); export default MemoizedOutput; +export const BASIC_OUTPUT_TAB_NAMES = ['Output', 'SourceMap']; + export type PrintedCompilerPipelineValue = | { kind: 'hir'; @@ -71,7 +75,7 @@ async function tabify( const concattedResults = new Map(); // Concat all top level function declaration results into a single tab for each pass for (const [passName, results] of compilerOutput.results) { - if (!showInternals && passName !== 'Output' && passName !== 'SourceMap') { + if (!showInternals && !BASIC_OUTPUT_TAB_NAMES.includes(passName)) { continue; } for (const result of results) { @@ -215,6 +219,7 @@ function Output({store, compilerOutput}: Props): JSX.Element { const [tabs, setTabs] = useState>( () => new Map(), ); + const [activeTab, setActiveTab] = useState('Output'); /* * Update the active tab back to the output or errors tab when the compilation state @@ -226,6 +231,7 @@ function Output({store, compilerOutput}: Props): JSX.Element { if (compilerOutput.kind !== previousOutputKind) { setPreviousOutputKind(compilerOutput.kind); setTabsOpen(new Set(['Output'])); + setActiveTab('Output'); } useEffect(() => { @@ -249,16 +255,24 @@ function Output({store, compilerOutput}: Props): JSX.Element { } } - return ( - <> + if (!store.showInternals) { + return ( - + ); + } + + return ( + ); } diff --git a/compiler/apps/playground/components/Header.tsx b/compiler/apps/playground/components/Header.tsx index 55f9dbdd36..582caebffb 100644 --- a/compiler/apps/playground/components/Header.tsx +++ b/compiler/apps/playground/components/Header.tsx @@ -72,7 +72,7 @@ export default function Header(): JSX.Element { 'before:bg-white before:rounded-full before:transition-transform before:duration-250', 'focus-within:shadow-[0_0_1px_#2196F3]', store.showInternals - ? 'bg-blue-500 before:translate-x-3.5' + ? 'bg-link before:translate-x-3.5' : 'bg-gray-300', )}> diff --git a/compiler/apps/playground/components/Icons/IconChevron.tsx b/compiler/apps/playground/components/Icons/IconChevron.tsx new file mode 100644 index 0000000000..1e9dfb6918 --- /dev/null +++ b/compiler/apps/playground/components/Icons/IconChevron.tsx @@ -0,0 +1,41 @@ +/** + * 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. + */ + +import {memo} from 'react'; + +export const IconChevron = memo< + JSX.IntrinsicElements['svg'] & { + /** + * The direction the arrow should point. + */ + displayDirection: 'right' | 'left'; + } +>(function IconChevron({className, displayDirection, ...props}) { + const rotationClass = + displayDirection === 'left' ? 'rotate-90' : '-rotate-90'; + const classes = className ? `${rotationClass} ${className}` : rotationClass; + + return ( + + + + + + + ); +}); diff --git a/compiler/apps/playground/components/TabbedWindow.tsx b/compiler/apps/playground/components/TabbedWindow.tsx index 4b01056f25..49ff76543b 100644 --- a/compiler/apps/playground/components/TabbedWindow.tsx +++ b/compiler/apps/playground/components/TabbedWindow.tsx @@ -4,103 +4,47 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ +import React from 'react'; +import clsx from 'clsx'; -import {Resizable} from 're-resizable'; -import React, {useCallback} from 'react'; - -type TabsRecord = Map; - -export default function TabbedWindow(props: { - defaultTab: string | null; - tabs: TabsRecord; - tabsOpen: Set; - setTabsOpen: (newTab: Set) => void; - changedPasses: Set; +export default function TabbedWindow({ + tabs, + activeTab, + onTabChange, +}: { + tabs: Map; + activeTab: string; + onTabChange: (tab: string) => void; }): React.ReactElement { - if (props.tabs.size === 0) { + if (tabs.size === 0) { return ( -
+
No compiler output detected, see errors below
); } return ( -
- {Array.from(props.tabs.keys()).map(name => { - return ( - - ); - })} -
- ); -} - -function TabbedWindowItem({ - name, - tabs, - tabsOpen, - setTabsOpen, - hasChanged, -}: { - name: string; - tabs: TabsRecord; - tabsOpen: Set; - setTabsOpen: (newTab: Set) => void; - hasChanged: boolean; -}): React.ReactElement { - const isShow = tabsOpen.has(name); - - const toggleTabs = useCallback(() => { - const nextState = new Set(tabsOpen); - if (nextState.has(name)) { - nextState.delete(name); - } else { - nextState.add(name); - } - setTabsOpen(nextState); - }, [tabsOpen, name, setTabsOpen]); - - // Replace spaces with non-breaking spaces - const displayName = name.replace(/ /g, '\u00A0'); - - return ( -
- {isShow ? ( - -

- - {displayName} -

- {tabs.get(name) ??
No output for {name}
} -
- ) : ( -
- -
- )} +
+
+ {Array.from(tabs.keys()).map(tab => { + const isActive = activeTab === tab; + return ( + + ); + })} +
+
+ {tabs.get(activeTab)} +
); } diff --git a/compiler/apps/playground/playwright.config.js b/compiler/apps/playground/playwright.config.js index 2ef29293d4..10de19457f 100644 --- a/compiler/apps/playground/playwright.config.js +++ b/compiler/apps/playground/playwright.config.js @@ -55,12 +55,16 @@ export default defineConfig({ // contextOptions: { // ignoreHTTPSErrors: true, // }, + viewport: {width: 1920, height: 1080}, }, projects: [ { name: 'chromium', - use: {...devices['Desktop Chrome']}, + use: { + ...devices['Desktop Chrome'], + viewport: {width: 1920, height: 1080}, + }, }, // { // name: 'Desktop Firefox',