main
tsx 422 lines 11.7 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8 import {
9 CodeIcon,
10 DocumentAddIcon,
11 InformationCircleIcon,
12 } from '@heroicons/react/outline';
13 import MonacoEditor, {DiffEditor} from '@monaco-editor/react';
14 import {
15 CompilerErrorDetail,
16 CompilerDiagnostic,
17 type CompilerError,
18 } from 'babel-plugin-react-compiler';
19 import parserBabel from 'prettier/plugins/babel';
20 import * as prettierPluginEstree from 'prettier/plugins/estree';
21 import * as prettier from 'prettier/standalone';
22 import {type Store} from '../../lib/stores';
23 import {
24 memo,
25 ReactNode,
26 use,
27 useState,
28 Suspense,
29 unstable_ViewTransition as ViewTransition,
30 unstable_addTransitionType as addTransitionType,
31 startTransition,
32 } from 'react';
33 import AccordionWindow from '../AccordionWindow';
34 import TabbedWindow from '../TabbedWindow';
35 import {monacoOptions} from './monacoOptions';
36 import {BabelFileResult} from '@babel/core';
37 import {
38 CONFIG_PANEL_TRANSITION,
39 TOGGLE_INTERNALS_TRANSITION,
40 EXPAND_ACCORDION_TRANSITION,
41 } from '../../lib/transitionTypes';
42 import {LRUCache} from 'lru-cache';
43
44 const MemoizedOutput = memo(Output);
45
46 export default MemoizedOutput;
47
48 export const BASIC_OUTPUT_TAB_NAMES = ['Output', 'SourceMap'];
49
50 const tabifyCache = new LRUCache<Store, Promise<Map<string, ReactNode>>>({
51 max: 5,
52 });
53
54 export type PrintedCompilerPipelineValue =
55 | {
56 kind: 'hir';
57 name: string;
58 fnName: string | null;
59 value: string;
60 }
61 | {kind: 'reactive'; name: string; fnName: string | null; value: string}
62 | {kind: 'debug'; name: string; fnName: string | null; value: string};
63
64 export type CompilerTransformOutput = {
65 code: string;
66 sourceMaps: BabelFileResult['map'];
67 language: 'flow' | 'typescript';
68 };
69 export type CompilerOutput =
70 | {
71 kind: 'ok';
72 transformOutput: CompilerTransformOutput;
73 results: Map<string, Array<PrintedCompilerPipelineValue>>;
74 errors: Array<CompilerErrorDetail | CompilerDiagnostic>;
75 }
76 | {
77 kind: 'err';
78 results: Map<string, Array<PrintedCompilerPipelineValue>>;
79 error: CompilerError;
80 };
81
82 type Props = {
83 store: Store;
84 compilerOutput: CompilerOutput;
85 };
86
87 async function tabify(
88 source: string,
89 compilerOutput: CompilerOutput,
90 showInternals: boolean,
91 ): Promise<Map<string, ReactNode>> {
92 const tabs = new Map<string, React.ReactNode>();
93 const reorderedTabs = new Map<string, React.ReactNode>();
94 const concattedResults = new Map<string, string>();
95 // Concat all top level function declaration results into a single tab for each pass
96 for (const [passName, results] of compilerOutput.results) {
97 if (!showInternals && !BASIC_OUTPUT_TAB_NAMES.includes(passName)) {
98 continue;
99 }
100 for (const result of results) {
101 switch (result.kind) {
102 case 'hir': {
103 const prev = concattedResults.get(result.name);
104 const next = result.value;
105 const identName = `function ${result.fnName}`;
106 if (prev != null) {
107 concattedResults.set(passName, `${prev}\n\n${identName}\n${next}`);
108 } else {
109 concattedResults.set(passName, `${identName}\n${next}`);
110 }
111 break;
112 }
113 case 'reactive': {
114 const prev = concattedResults.get(passName);
115 const next = result.value;
116 if (prev != null) {
117 concattedResults.set(passName, `${prev}\n\n${next}`);
118 } else {
119 concattedResults.set(passName, next);
120 }
121 break;
122 }
123 case 'debug': {
124 concattedResults.set(passName, result.value);
125 break;
126 }
127 default: {
128 const _: never = result;
129 throw new Error('Unexpected result kind');
130 }
131 }
132 }
133 }
134 let lastPassOutput: string | null = null;
135 let nonDiffPasses = ['HIR', 'BuildReactiveFunction', 'EnvironmentConfig'];
136 for (const [passName, text] of concattedResults) {
137 tabs.set(
138 passName,
139 <TextTabContent
140 output={text}
141 diff={lastPassOutput}
142 showInfoPanel={!nonDiffPasses.includes(passName)}></TextTabContent>,
143 );
144 lastPassOutput = text;
145 }
146 // Ensure that JS and the JS source map come first
147 if (compilerOutput.kind === 'ok') {
148 const {transformOutput} = compilerOutput;
149 const sourceMapUrl = getSourceMapUrl(
150 transformOutput.code,
151 JSON.stringify(transformOutput.sourceMaps),
152 );
153 const code = await prettier.format(transformOutput.code, {
154 semi: true,
155 parser: transformOutput.language === 'flow' ? 'babel-flow' : 'babel-ts',
156 plugins: [parserBabel, prettierPluginEstree],
157 });
158
159 let output: string;
160 let language: string;
161 if (compilerOutput.errors.length === 0) {
162 output = code;
163 language = 'javascript';
164 } else {
165 language = 'markdown';
166 output = `
167 # Summary
168
169 React Compiler compiled this function successfully, but there are lint errors that indicate potential issues with the original code.
170
171 ## ${compilerOutput.errors.length} Lint Errors
172
173 ${compilerOutput.errors.map(e => e.printErrorMessage(source, {eslint: false})).join('\n\n')}
174
175 ## Output
176
177 \`\`\`js
178 ${code}
179 \`\`\`
180 `.trim();
181 }
182
183 reorderedTabs.set(
184 'Output',
185 <TextTabContent
186 output={output}
187 language={language}
188 diff={null}
189 showInfoPanel={false}></TextTabContent>,
190 );
191 if (sourceMapUrl) {
192 reorderedTabs.set(
193 'SourceMap',
194 <>
195 <iframe
196 src={sourceMapUrl}
197 className="w-full h-monaco_small sm:h-monaco"
198 title="Generated Code"
199 />
200 </>,
201 );
202 }
203 } else if (compilerOutput.kind === 'err') {
204 const errors = compilerOutput.error.printErrorMessage(source, {
205 eslint: false,
206 });
207 reorderedTabs.set(
208 'Output',
209 <TextTabContent
210 output={errors}
211 language="markdown"
212 diff={null}
213 showInfoPanel={false}></TextTabContent>,
214 );
215 }
216 tabs.forEach((tab, name) => {
217 reorderedTabs.set(name, tab);
218 });
219 return reorderedTabs;
220 }
221
222 function tabifyCached(
223 store: Store,
224 compilerOutput: CompilerOutput,
225 ): Promise<Map<string, ReactNode>> {
226 const cached = tabifyCache.get(store);
227 if (cached) return cached;
228 const result = tabify(store.source, compilerOutput, store.showInternals);
229 tabifyCache.set(store, result);
230 return result;
231 }
232
233 function Fallback(): JSX.Element {
234 return (
235 <div className="w-full h-monaco_small sm:h-monaco flex items-center justify-center">
236 Loading...
237 </div>
238 );
239 }
240
241 function utf16ToUTF8(s: string): string {
242 return unescape(encodeURIComponent(s));
243 }
244
245 function getSourceMapUrl(code: string, map: string): string | null {
246 code = utf16ToUTF8(code);
247 map = utf16ToUTF8(map);
248 return `https://evanw.github.io/source-map-visualization/#${btoa(
249 `${code.length}\0${code}${map.length}\0${map}`,
250 )}`;
251 }
252
253 function Output({store, compilerOutput}: Props): JSX.Element {
254 return (
255 <Suspense fallback={<Fallback />}>
256 <OutputContent store={store} compilerOutput={compilerOutput} />
257 </Suspense>
258 );
259 }
260
261 function OutputContent({store, compilerOutput}: Props): JSX.Element {
262 const [tabsOpen, setTabsOpen] = useState<Set<string>>(
263 () => new Set(['Output']),
264 );
265 const [activeTab, setActiveTab] = useState<string>('Output');
266
267 /*
268 * Update the active tab back to the output or errors tab when the compilation state
269 * changes between success/failure.
270 */
271 const [previousOutputKind, setPreviousOutputKind] = useState(
272 compilerOutput.kind,
273 );
274 const isFailure = compilerOutput.kind !== 'ok';
275
276 if (compilerOutput.kind !== previousOutputKind) {
277 setPreviousOutputKind(compilerOutput.kind);
278 if (isFailure) {
279 startTransition(() => {
280 addTransitionType(EXPAND_ACCORDION_TRANSITION);
281 setTabsOpen(prev => new Set(prev).add('Output'));
282 setActiveTab('Output');
283 });
284 }
285 }
286
287 const changedPasses: Set<string> = new Set(['Output', 'HIR']); // Initial and final passes should always be bold
288 let lastResult: string = '';
289 for (const [passName, results] of compilerOutput.results) {
290 for (const result of results) {
291 let currResult = '';
292 if (result.kind === 'hir' || result.kind === 'reactive') {
293 currResult += `function ${result.fnName}\n\n${result.value}`;
294 }
295 if (currResult !== lastResult) {
296 changedPasses.add(passName);
297 }
298 lastResult = currResult;
299 }
300 }
301 const tabs = use(tabifyCached(store, compilerOutput));
302
303 if (!store.showInternals) {
304 return (
305 <ViewTransition
306 update={{
307 [CONFIG_PANEL_TRANSITION]: 'container',
308 [TOGGLE_INTERNALS_TRANSITION]: '',
309 default: 'none',
310 }}>
311 <TabbedWindow
312 tabs={tabs}
313 activeTab={activeTab}
314 onTabChange={setActiveTab}
315 />
316 </ViewTransition>
317 );
318 }
319
320 return (
321 <ViewTransition
322 update={{
323 [CONFIG_PANEL_TRANSITION]: 'accordion-container',
324 [TOGGLE_INTERNALS_TRANSITION]: '',
325 default: 'none',
326 }}>
327 <AccordionWindow
328 defaultTab={store.showInternals ? 'HIR' : 'Output'}
329 setTabsOpen={setTabsOpen}
330 tabsOpen={tabsOpen}
331 tabs={tabs}
332 changedPasses={changedPasses}
333 />
334 </ViewTransition>
335 );
336 }
337
338 function TextTabContent({
339 output,
340 diff,
341 showInfoPanel,
342 language,
343 }: {
344 output: string;
345 diff: string | null;
346 showInfoPanel: boolean;
347 language: string;
348 }): JSX.Element {
349 const [diffMode, setDiffMode] = useState(false);
350 return (
351 /**
352 * Restrict MonacoEditor's height, since the config autoLayout:true
353 * will grow the editor to fit within parent element
354 */
355 <div className="w-full h-monaco_small sm:h-monaco">
356 {showInfoPanel ? (
357 <div className="flex items-center gap-1 bg-amber-50 p-2">
358 {diff != null && output !== diff ? (
359 <button
360 className="flex items-center gap-1 transition-colors duration-150 ease-in text-secondary hover:text-link"
361 onClick={() => setDiffMode(diffMode => !diffMode)}>
362 {!diffMode ? (
363 <>
364 <DocumentAddIcon className="w-5 h-5" /> Show Diff
365 </>
366 ) : (
367 <>
368 <CodeIcon className="w-5 h-5" /> Show Output
369 </>
370 )}
371 </button>
372 ) : (
373 <>
374 <span className="flex items-center gap-1">
375 <InformationCircleIcon className="w-5 h-5" /> No changes from
376 previous pass
377 </span>
378 </>
379 )}
380 </div>
381 ) : null}
382 {diff != null && diffMode ? (
383 <DiffEditor
384 original={diff}
385 modified={output}
386 loading={''}
387 options={{
388 ...monacoOptions,
389 scrollbar: {
390 vertical: 'hidden',
391 },
392 dimension: {
393 width: 0,
394 height: 0,
395 },
396 readOnly: true,
397 lineNumbers: 'off',
398 glyphMargin: false,
399 // Undocumented see https://github.com/Microsoft/vscode/issues/30795#issuecomment-410998882
400 overviewRulerLanes: 0,
401 }}
402 />
403 ) : (
404 <MonacoEditor
405 language={language ?? 'javascript'}
406 value={output}
407 loading={''}
408 className="monaco-editor-output"
409 options={{
410 ...monacoOptions,
411 readOnly: true,
412 lineNumbers: 'off',
413 glyphMargin: false,
414 // Undocumented see https://github.com/Microsoft/vscode/issues/30795#issuecomment-410998882
415 lineDecorationsWidth: 0,
416 lineNumbersMinChars: 0,
417 }}
418 />
419 )}
420 </div>
421 );
422 }