diff --git a/compiler/apps/playground/components/Editor/ConfigEditor.tsx b/compiler/apps/playground/components/Editor/ConfigEditor.tsx index ce0e502fac21d..43b3f1e8a9164 100644 --- a/compiler/apps/playground/components/Editor/ConfigEditor.tsx +++ b/compiler/apps/playground/components/Editor/ConfigEditor.tsx @@ -8,58 +8,109 @@ import MonacoEditor, {loader, type Monaco} from '@monaco-editor/react'; import type {editor} from 'monaco-editor'; import * as monaco from 'monaco-editor'; -import {useState} from 'react'; +import React, {useState, useCallback} from 'react'; import {Resizable} from 're-resizable'; +import {useSnackbar} from 'notistack'; import {useStore, useStoreDispatch} from '../StoreContext'; import {monacoOptions} from './monacoOptions'; import { + ConfigError, generateOverridePragmaFromConfig, updateSourceWithOverridePragma, } from '../../lib/configUtils'; +// @ts-expect-error - webpack asset/source loader handles .d.ts files as strings +import compilerTypeDefs from 'babel-plugin-react-compiler/dist/index.d.ts'; + loader.config({monaco}); -export default function ConfigEditor(): JSX.Element { - const [, setMonaco] = useState(null); +export default function ConfigEditor(): React.ReactElement { + const [isExpanded, setIsExpanded] = useState(false); const store = useStore(); const dispatchStore = useStoreDispatch(); + const {enqueueSnackbar} = useSnackbar(); - const handleChange: (value: string | undefined) => void = async value => { - if (value === undefined) return; + const toggleExpanded = useCallback(() => { + setIsExpanded(prev => !prev); + }, []); + const handleApplyConfig: () => Promise = async () => { try { - const newPragma = await generateOverridePragmaFromConfig(value); + const config = store.config || ''; + + if (!config.trim()) { + enqueueSnackbar( + 'Config is empty. Please add configuration options first.', + { + variant: 'warning', + }, + ); + return; + } + const newPragma = await generateOverridePragmaFromConfig(config); const updatedSource = updateSourceWithOverridePragma( store.source, newPragma, ); - // Update the store with both the new config and updated source dispatchStore({ type: 'updateFile', payload: { source: updatedSource, - config: value, - }, - }); - } catch (_) { - dispatchStore({ - type: 'updateFile', - payload: { - source: store.source, - config: value, + config: config, }, }); + } catch (error) { + console.error('Failed to apply config:', error); + + if (error instanceof ConfigError && error.message.trim()) { + enqueueSnackbar(error.message, { + variant: 'error', + }); + } else { + enqueueSnackbar('Unexpected error: failed to apply config.', { + variant: 'error', + }); + } } }; + const handleChange: (value: string | undefined) => void = value => { + if (value === undefined) return; + + // Only update the config + dispatchStore({ + type: 'updateFile', + payload: { + source: store.source, + config: value, + }, + }); + }; + const handleMount: ( _: editor.IStandaloneCodeEditor, monaco: Monaco, ) => void = (_, monaco) => { - setMonaco(monaco); + // Add the babel-plugin-react-compiler type definitions to Monaco + monaco.languages.typescript.typescriptDefaults.addExtraLib( + //@ts-expect-error - compilerTypeDefs is a string + compilerTypeDefs, + 'file:///node_modules/babel-plugin-react-compiler/dist/index.d.ts', + ); + monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ + target: monaco.languages.typescript.ScriptTarget.Latest, + allowNonTsExtensions: true, + moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs, + module: monaco.languages.typescript.ModuleKind.ESNext, + noEmit: true, + strict: false, + esModuleInterop: true, + allowSyntheticDefaultImports: true, + jsx: monaco.languages.typescript.JsxEmit.React, + }); - const uri = monaco.Uri.parse(`file:///config.js`); + const uri = monaco.Uri.parse(`file:///config.ts`); const model = monaco.editor.getModel(uri); if (model) { model.updateOptions({tabSize: 2}); @@ -67,35 +118,66 @@ export default function ConfigEditor(): JSX.Element { }; return ( -
-

- Config Overrides -

- - - +
+ {isExpanded ? ( + <> + +

+ - Config Overrides +

+
+ +
+
+ + + ) : ( +
+ +
+ )}
); } diff --git a/compiler/apps/playground/components/Editor/EditorImpl.tsx b/compiler/apps/playground/components/Editor/EditorImpl.tsx index 6d7dd73e6d148..d7229361d39c2 100644 --- a/compiler/apps/playground/components/Editor/EditorImpl.tsx +++ b/compiler/apps/playground/components/Editor/EditorImpl.tsx @@ -48,7 +48,6 @@ import { import {transformFromAstSync} from '@babel/core'; import {LoggerEvent} from 'babel-plugin-react-compiler/dist/Entrypoint'; import {useSearchParams} from 'next/navigation'; -import {parseAndFormatConfig} from '../../lib/configUtils'; function parseInput( input: string, @@ -317,16 +316,11 @@ export default function Editor(): JSX.Element { mountStore = defaultStore; } - parseAndFormatConfig(mountStore.source).then(config => { - dispatchStore({ - type: 'setStore', - payload: { - store: { - ...mountStore, - config, - }, - }, - }); + dispatchStore({ + type: 'setStore', + payload: { + store: mountStore, + }, }); }); diff --git a/compiler/apps/playground/lib/configUtils.ts b/compiler/apps/playground/lib/configUtils.ts index d987406f99892..108ba759d1384 100644 --- a/compiler/apps/playground/lib/configUtils.ts +++ b/compiler/apps/playground/lib/configUtils.ts @@ -8,8 +8,15 @@ import parserBabel from 'prettier/plugins/babel'; import prettierPluginEstree from 'prettier/plugins/estree'; import * as prettier from 'prettier/standalone'; +import {parsePluginOptions} from 'babel-plugin-react-compiler'; import {parseConfigPragmaAsString} from '../../../packages/babel-plugin-react-compiler/src/Utils/TestUtils'; +export class ConfigError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConfigError'; + } +} /** * Parse config from pragma and format it with prettier */ @@ -17,7 +24,10 @@ export async function parseAndFormatConfig(source: string): Promise { const pragma = source.substring(0, source.indexOf('\n')); let configString = parseConfigPragmaAsString(pragma); if (configString !== '') { - configString = `(${configString})`; + configString = `\ + import type { PluginOptions } from 'babel-plugin-react-compiler/dist'; + + (${configString} satisfies Partial)`; } try { @@ -34,10 +44,10 @@ export async function parseAndFormatConfig(source: string): Promise { } function extractCurlyBracesContent(input: string): string { - const startIndex = input.indexOf('{'); + const startIndex = input.indexOf('({') + 1; const endIndex = input.lastIndexOf('}'); if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) { - throw new Error('No outer curly braces found in input'); + throw new Error('No outer curly braces found in input.'); } return input.slice(startIndex, endIndex + 1); } @@ -49,6 +59,27 @@ function cleanContent(content: string): string { .trim(); } +/** + * Validate that a config string can be parsed as a valid PluginOptions object + * Throws an error if validation fails. + */ +function validateConfigAsPluginOptions(configString: string): void { + // Validate that config can be parse as JS obj + let parsedConfig: unknown; + try { + parsedConfig = new Function(`return (${configString})`)(); + } catch (_) { + throw new ConfigError('Config has invalid syntax.'); + } + + // Validate against PluginOptions schema + try { + parsePluginOptions(parsedConfig); + } catch (_) { + throw new ConfigError('Config does not match the expected schema.'); + } +} + /** * Generate a the override pragma comment from a formatted config object string */ @@ -58,6 +89,8 @@ export async function generateOverridePragmaFromConfig( const content = extractCurlyBracesContent(formattedConfigString); const cleanConfig = cleanContent(content); + validateConfigAsPluginOptions(cleanConfig); + // Format the config to ensure it's valid await prettier.format(`(${cleanConfig})`, { semi: false, diff --git a/compiler/apps/playground/lib/defaultStore.ts b/compiler/apps/playground/lib/defaultStore.ts index 1031a830fa0d9..ee4dc7e3b0246 100644 --- a/compiler/apps/playground/lib/defaultStore.ts +++ b/compiler/apps/playground/lib/defaultStore.ts @@ -13,9 +13,31 @@ export default function MyApp() { } `; +export const defaultConfig = `\ +import type { PluginOptions } from 'babel-plugin-react-compiler/dist'; + +({ + compilationMode: 'infer', + panicThreshold: 'none', + environment: {}, + logger: null, + gating: null, + noEmit: false, + dynamicGating: null, + eslintSuppressionRules: null, + flowSuppressions: true, + ignoreUseNoForget: false, + sources: filename => { + return filename.indexOf('node_modules') === -1; + }, + enableReanimatedCheck: true, + customOptOutDirectives: null, + target: '19', +} satisfies Partial);`; + export const defaultStore: Store = { source: index, - config: '', + config: defaultConfig, }; export const emptyStore: Store = { diff --git a/compiler/apps/playground/lib/stores/store.ts b/compiler/apps/playground/lib/stores/store.ts index e37140cb25259..3ea8c5cc2330a 100644 --- a/compiler/apps/playground/lib/stores/store.ts +++ b/compiler/apps/playground/lib/stores/store.ts @@ -10,7 +10,7 @@ import { compressToEncodedURIComponent, decompressFromEncodedURIComponent, } from 'lz-string'; -import {defaultStore} from '../defaultStore'; +import {defaultStore, defaultConfig} from '../defaultStore'; /** * Global Store for Playground @@ -68,10 +68,10 @@ export function initStoreFromUrlOrLocalStorage(): Store { invariant(isValidStore(raw), 'Invalid Store'); // Add config property if missing for backwards compatibility - if (!('config' in raw)) { + if (!('config' in raw) || !raw['config']) { return { ...raw, - config: '', + config: defaultConfig, }; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/Utils/TestUtils.ts b/compiler/packages/babel-plugin-react-compiler/src/Utils/TestUtils.ts index c384165c31260..5188849f1e973 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Utils/TestUtils.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Utils/TestUtils.ts @@ -253,8 +253,6 @@ function parseConfigStringAsJS( }); } - console.log('OVERRIDE:', parsedConfig); - const environment = parseConfigPragmaEnvironmentForTest( '', defaults.environment ?? {},