Skip to content
Merged
103 changes: 103 additions & 0 deletions compiler/apps/playground/components/Editor/ConfigEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* 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 MonacoEditor, {loader, type Monaco} from '@monaco-editor/react';
import {parseConfigPragmaAsString} from 'babel-plugin-react-compiler';
import type {editor} from 'monaco-editor';
import * as monaco from 'monaco-editor';
import parserBabel from 'prettier/plugins/babel';
import * as prettierPluginEstree from 'prettier/plugins/estree';
import * as prettier from 'prettier/standalone';
import {useState, useEffect} from 'react';
import {Resizable} from 're-resizable';
import {useStore} from '../StoreContext';
import {monacoOptions} from './monacoOptions';

loader.config({monaco});

export default function ConfigEditor(): JSX.Element {
const [, setMonaco] = useState<Monaco | null>(null);
const store = useStore();

// Parse string-based override config from pragma comment and format it
const [configJavaScript, setConfigJavaScript] = useState('');

useEffect(() => {
const pragma = store.source.substring(0, store.source.indexOf('\n'));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could compare this against the previous value to bail out of the effect early if you don't need to sync again.

const previousPragma = useRef()

useEffect(()=> {
  const pragma = store.source.substring(0, store.source.indexOf('\n'));
  if (pragma === previousPragma.current) {
    return;
  }
  previousPragma.current = pragma;

  // ...
}, [])

An alternative approach (can be a followup PR) is to avoid this effect altogether by moving this into the event. Currently the flow is

  • User updates editor content
  • Content onChange fires
  • editor state update dispatch to context
  • useStore() is subscribed to changed context
  • ConfigEditor rerenders
  • effect fires
  • prettier runs, sets state
  • ConfigEditor rerenders

If we refactor the store to have a config property, it could be

  • User updates editor content
  • Content onChange fires
  • pragma is parsed, if it has changed format and dispatch the updated config state in a callback
  • ConfigEditor renders latest state from context

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the suggestions! Yea definitely planning to fix the re-renders in a later commit, also going to try to fix the rerenders of the other components that use useEffect and useMemo incorrectly.

const configString = `(${parseConfigPragmaAsString(pragma)})`;

prettier
.format(configString, {
semi: true,
parser: 'babel-ts',
plugins: [parserBabel, prettierPluginEstree],
})
.then(formatted => {
setConfigJavaScript(formatted);
})
.catch(error => {
console.error('Error formatting config:', error);
setConfigJavaScript('({})'); // Return empty object if not valid for now
//TODO: Add validation and error handling for config
});
console.log('Config:', configString);
}, [store.source]);

const handleChange: (value: string | undefined) => void = value => {
if (!value) return;

// TODO: Implement sync logic to update pragma comments in the source
console.log('Config changed:', value);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's disable the editor while we don't have this hooked up. Output tab does this for example. In a future PR when the handler is added it can be re-enabled.

};

const handleMount: (
_: editor.IStandaloneCodeEditor,
monaco: Monaco,
) => void = (_, monaco) => {
setMonaco(monaco);

const uri = monaco.Uri.parse(`file:///config.js`);
const model = monaco.editor.getModel(uri);
if (model) {
model.updateOptions({tabSize: 2});
}
};

return (
<div className="relative flex flex-col flex-none border-r border-gray-200">
<h2 className="p-4 duration-150 ease-in border-b cursor-default border-grey-200 font-light text-secondary">
Config Overrides
</h2>
<Resizable
minWidth={300}
maxWidth={600}
defaultSize={{width: 350, height: 'auto'}}
enable={{right: true}}
className="!h-[calc(100vh_-_3.5rem_-_4rem)]">
<MonacoEditor
path={'config.js'}
language={'javascript'}
value={configJavaScript}
onMount={handleMount}
onChange={handleChange}
options={{
...monacoOptions,
readOnly: true,
lineNumbers: 'off',
folding: false,
renderLineHighlight: 'none',
scrollBeyondLastLine: false,
hideCursorInOverviewRuler: true,
overviewRulerBorder: false,
overviewRulerLanes: 0,
fontSize: 12,
}}
/>
</Resizable>
</div>
);
}
9 changes: 9 additions & 0 deletions compiler/apps/playground/components/Editor/EditorImpl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
type Store,
} from '../../lib/stores';
import {useStore, useStoreDispatch} from '../StoreContext';
import ConfigEditor from './ConfigEditor';
import Input from './Input';
import {
CompilerOutput,
Expand All @@ -46,6 +47,7 @@ import {
} from './Output';
import {transformFromAstSync} from '@babel/core';
import {LoggerEvent} from 'babel-plugin-react-compiler/dist/Entrypoint';
import {useSearchParams} from 'next/navigation';

function parseInput(
input: string,
Expand Down Expand Up @@ -291,7 +293,13 @@ export default function Editor(): JSX.Element {
[deferredStore.source],
);

// TODO: Remove this once the config editor is more stable
const searchParams = useSearchParams();
const search = searchParams.get('showConfig');
const shouldShowConfig = search === 'true';

useMountEffect(() => {
// Initialize store
let mountStore: Store;
try {
mountStore = initStoreFromUrlOrLocalStorage();
Expand Down Expand Up @@ -328,6 +336,7 @@ export default function Editor(): JSX.Element {
return (
<>
<div className="relative flex basis top-14">
{shouldShowConfig && <ConfigEditor />}
<div className={clsx('relative sm:basis-1/4')}>
<Input language={language} errors={errors} />
</div>
Expand Down
106 changes: 106 additions & 0 deletions compiler/packages/babel-plugin-react-compiler/src/Utils/TestUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,11 @@ export function parseConfigPragmaForTests(
environment?: PartialEnvironmentConfig;
},
): PluginOptions {
const overridePragma = parseConfigPragmaAsString(pragma);
if (overridePragma !== '') {
return parseConfigStringAsJS(overridePragma, defaults);
}

const environment = parseConfigPragmaEnvironmentForTest(
pragma,
defaults.environment ?? {},
Expand Down Expand Up @@ -217,3 +222,104 @@ export function parseConfigPragmaForTests(
}
return parsePluginOptions(options);
}

export function parseConfigPragmaAsString(pragma: string): string {
// Check if it's in JS override format
for (const {key, value: val} of splitPragma(pragma)) {
if (key === 'OVERRIDE' && val != null) {
return val;
}
}
return '';
}

function parseConfigStringAsJS(
configString: string,
defaults: {
compilationMode: CompilationMode;
environment?: PartialEnvironmentConfig;
},
): PluginOptions {
let parsedConfig: any;
try {
// Parse the JavaScript object literal
parsedConfig = new Function(`return ${configString}`)();
} catch (error) {
CompilerError.invariant(false, {
reason: 'Failed to parse config pragma as JavaScript object',
description: `Could not parse: ${configString}. Error: ${error}`,
loc: null,
suggestions: null,
});
}

console.log('OVERRIDE:', parsedConfig);

const options: Record<keyof PluginOptions, unknown> = {
...defaultOptions,
panicThreshold: 'all_errors',
compilationMode: defaults.compilationMode,
environment: defaults.environment ?? defaultOptions.environment,
};

// Apply parsed config, merging environment if it exists
if (parsedConfig.environment) {
const mergedEnvironment = {
...(options.environment as Record<string, unknown>),
...parsedConfig.environment,
};

// Apply complex defaults for environment flags that are set to true
const environmentConfig: Partial<Record<keyof EnvironmentConfig, unknown>> =
{};
for (const [key, value] of Object.entries(mergedEnvironment)) {
if (hasOwnProperty(EnvironmentConfigSchema.shape, key)) {
if (value === true && key in testComplexConfigDefaults) {
environmentConfig[key] = testComplexConfigDefaults[key];
} else {
environmentConfig[key] = value;
}
}
}

// Validate environment config
const validatedEnvironment =
EnvironmentConfigSchema.safeParse(environmentConfig);
if (!validatedEnvironment.success) {
CompilerError.invariant(false, {
reason: 'Invalid environment configuration in config pragma',
description: `${fromZodError(validatedEnvironment.error)}`,
loc: null,
suggestions: null,
});
}

if (validatedEnvironment.data.enableResetCacheOnSourceFileChanges == null) {
validatedEnvironment.data.enableResetCacheOnSourceFileChanges = false;
}

options.environment = validatedEnvironment.data;
}

// Apply other config options
for (const [key, value] of Object.entries(parsedConfig)) {
if (key === 'environment') {
continue;
}

if (hasOwnProperty(defaultOptions, key)) {
if (value === true && key in testComplexPluginOptionDefaults) {
options[key] = testComplexPluginOptionDefaults[key];
} else if (key === 'target' && value === 'donotuse_meta_internal') {
options[key] = {
kind: value,
runtimeModule: 'react',
};
} else {
options[key] = value;
}
}
}

return parsePluginOptions(options);
}
5 changes: 4 additions & 1 deletion compiler/packages/babel-plugin-react-compiler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ export {
printReactiveFunction,
printReactiveFunctionWithOutlined,
} from './ReactiveScopes';
export {parseConfigPragmaForTests} from './Utils/TestUtils';
export {
parseConfigPragmaForTests,
parseConfigPragmaAsString,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we want this to be a top-level export of the compiler package. Especially if this logic is specific to the playground app. Though I see parseConfigPragmaForTests so @mofeiZ might have a better opinion.

Seems like how you had it before in the ConfigEditor component module would be ok.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The compiler package exposes a lot of implementation details already in its exports so either is fine 😅

It isn't necessary to expose this as a top level export though, you can import directly from a compiler source file. This is because next-js (e.g. compiler playground) does its own bundling which means it currently recursively traverses babel-plugin-react-compiler source files instead of using the pre-bundled version

} from './Utils/TestUtils';
declare global {
let __DEV__: boolean | null | undefined;
}
Expand Down
Loading