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 0084911eec1b7..ab7b3ce58cf7b 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 0000000000000..de3b01b0b05d3 --- /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 63522987db052..5f904960bacbe 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 + +
+
+

+ Config Overrides

-
- -
- - ) : ( -
- + />
- )} +
+ + ); +} + +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 d6a2bccc8edef..a90447c96b50a 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}`); + baseOpts = parseOptions(source, mode, configOverrides); + } catch (err) { + 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}`); + } } - } - }; - 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, + }; + // 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); } - : { - /* 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); - } 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 { + }; + 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 f4c64a14a0501..206b98300be43 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 ae8154f589efa..22f908e51bbdb 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 55f9dbdd36c33..582caebffb9c3 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 0000000000000..1e9dfb69188a9 --- /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 4b01056f25bb7..49ff76543bb5c 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 2ef29293d412b..10de19457ff0b 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',