main
ts 401 lines 11.8 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 type * as BabelCore from '@babel/core';
9 import {transformFromAstSync} from '@babel/core';
10
11 import * as BabelParser from '@babel/parser';
12 import {NodePath} from '@babel/traverse';
13 import * as t from '@babel/types';
14 import type {
15 Logger,
16 LoggerEvent,
17 PluginOptions,
18 CompilerReactTarget,
19 CompilerPipelineValue,
20 } from 'babel-plugin-react-compiler/src/Entrypoint';
21 import type {
22 Effect,
23 ValueKind,
24 ValueReason,
25 } from 'babel-plugin-react-compiler/src/HIR';
26 import type {parseConfigPragmaForTests as ParseConfigPragma} from 'babel-plugin-react-compiler/src/Utils/TestUtils';
27 import * as HermesParser from 'hermes-parser';
28 import invariant from 'invariant';
29 import path from 'path';
30 import prettier from 'prettier';
31 import SproutTodoFilter from './SproutTodoFilter';
32 import {isExpectError} from './fixture-utils';
33 import {makeSharedRuntimeTypeProvider} from './sprout/shared-runtime-type-provider';
34
35 export function parseLanguage(source: string): 'flow' | 'typescript' {
36 return source.indexOf('@flow') !== -1 ? 'flow' : 'typescript';
37 }
38
39 export function parseSourceType(source: string): 'script' | 'module' {
40 return source.indexOf('@script') !== -1 ? 'script' : 'module';
41 }
42
43 /**
44 * Parse react compiler plugin + environment options from test fixture. Note
45 * that although this primarily uses `Environment:parseConfigPragma`, it also
46 * has test fixture specific (i.e. not applicable to playground) parsing logic.
47 */
48 function makePluginOptions(
49 firstLine: string,
50 parseConfigPragmaFn: typeof ParseConfigPragma,
51 debugIRLogger: (value: CompilerPipelineValue) => void,
52 EffectEnum: typeof Effect,
53 ValueKindEnum: typeof ValueKind,
54 ValueReasonEnum: typeof ValueReason,
55 ): {
56 options: PluginOptions;
57 loggerTestOnly: boolean;
58 logs: Array<{filename: string | null; event: LoggerEvent}>;
59 } {
60 // TODO(@mofeiZ) rewrite snap fixtures to @validatePreserveExistingMemo:false
61 let validatePreserveExistingMemoizationGuarantees = false;
62 let target: CompilerReactTarget = '19';
63
64 /**
65 * Snap currently runs all fixtures without `validatePreserveExistingMemo` as
66 * most fixtures are interested in compilation output, not whether the
67 * compiler was able to preserve existing memo.
68 *
69 * TODO: flip the default. `useMemo` is rare in test fixtures -- fixtures that
70 * use useMemo should be explicit about whether this flag is enabled
71 */
72 if (firstLine.includes('@validatePreserveExistingMemoizationGuarantees')) {
73 validatePreserveExistingMemoizationGuarantees = true;
74 }
75
76 const loggerTestOnly = firstLine.includes('@loggerTestOnly');
77 const logs: Array<{filename: string | null; event: LoggerEvent}> = [];
78 const logger: Logger = {
79 logEvent: (filename, event) => {
80 logs.push({filename, event});
81 },
82 debugLogIRs: debugIRLogger,
83 };
84
85 const config = parseConfigPragmaFn(firstLine, {compilationMode: 'all'});
86 const options = {
87 ...config,
88 environment: {
89 ...config.environment,
90 moduleTypeProvider: makeSharedRuntimeTypeProvider({
91 EffectEnum,
92 ValueKindEnum,
93 ValueReasonEnum,
94 }),
95 assertValidMutableRanges: true,
96 validatePreserveExistingMemoizationGuarantees,
97 },
98 logger,
99 enableReanimatedCheck: false,
100 target,
101 };
102 return {options, loggerTestOnly, logs};
103 }
104
105 export function parseInput(
106 input: string,
107 filename: string,
108 language: 'flow' | 'typescript',
109 sourceType: 'module' | 'script',
110 ): BabelCore.types.File {
111 // Extract the first line to quickly check for custom test directives
112 if (language === 'flow') {
113 return HermesParser.parse(input, {
114 babel: true,
115 flow: 'all',
116 sourceFilename: filename,
117 sourceType,
118 enableExperimentalComponentSyntax: true,
119 enableExperimentalFlowMatchSyntax: true,
120 });
121 } else {
122 return BabelParser.parse(input, {
123 sourceFilename: filename,
124 plugins: ['typescript', 'jsx', 'explicitResourceManagement'],
125 sourceType,
126 });
127 }
128 }
129
130 function getEvaluatorPresets(
131 language: 'typescript' | 'flow',
132 ): Array<BabelCore.PluginItem> {
133 const presets: Array<BabelCore.PluginItem> = [
134 {
135 plugins: [
136 'babel-plugin-fbt',
137 'babel-plugin-fbt-runtime',
138 'babel-plugin-idx',
139 ],
140 },
141 ];
142 presets.push(
143 language === 'typescript'
144 ? [
145 '@babel/preset-typescript',
146 {
147 /**
148 * onlyRemoveTypeImports needs to be set as fbt imports
149 * would otherwise be removed by this pass.
150 * https://github.com/facebook/fbt/issues/49
151 * https://github.com/facebook/sfbt/issues/72
152 * https://dev.to/retyui/how-to-add-support-typescript-for-fbt-an-internationalization-framework-3lo0
153 */
154 onlyRemoveTypeImports: true,
155 },
156 ]
157 : '@babel/preset-flow',
158 );
159
160 presets.push({
161 plugins: ['@babel/plugin-syntax-jsx'],
162 });
163 presets.push(
164 ['@babel/preset-react', {throwIfNamespace: false}],
165 {
166 plugins: ['@babel/plugin-transform-modules-commonjs'],
167 },
168 {
169 plugins: [
170 function BabelPluginRewriteRequirePath() {
171 return {
172 visitor: {
173 CallExpression(path: NodePath<t.CallExpression>) {
174 const {callee} = path.node;
175 if (callee.type === 'Identifier' && callee.name === 'require') {
176 const arg = path.node.arguments[0];
177 if (arg.type === 'StringLiteral') {
178 // rewrite to use relative import as eval happens in
179 // sprout/evaluator.ts
180 if (arg.value === 'shared-runtime') {
181 arg.value = './shared-runtime';
182 } else if (arg.value === 'ReactForgetFeatureFlag') {
183 arg.value = './ReactForgetFeatureFlag';
184 } else if (arg.value === 'useEffectWrapper') {
185 arg.value = './useEffectWrapper';
186 }
187 }
188 }
189 },
190 },
191 };
192 },
193 ],
194 },
195 );
196 return presets;
197 }
198 async function format(
199 inputCode: string,
200 language: 'typescript' | 'flow',
201 ): Promise<string> {
202 return await prettier.format(inputCode, {
203 semi: true,
204 parser: language === 'typescript' ? 'babel-ts' : 'flow',
205 });
206 }
207 const TypescriptEvaluatorPresets = getEvaluatorPresets('typescript');
208 const FlowEvaluatorPresets = getEvaluatorPresets('flow');
209
210 export type TransformResult = {
211 forgetOutput: string;
212 logs: string | null;
213 evaluatorCode: {
214 original: string;
215 forget: string;
216 } | null;
217 };
218
219 export async function transformFixtureInput(
220 input: string,
221 fixturePath: string,
222 parseConfigPragmaFn: typeof ParseConfigPragma,
223 plugin: BabelCore.PluginObj,
224 includeEvaluator: boolean,
225 debugIRLogger: (value: CompilerPipelineValue) => void,
226 EffectEnum: typeof Effect,
227 ValueKindEnum: typeof ValueKind,
228 ValueReasonEnum: typeof ValueReason,
229 ): Promise<{kind: 'ok'; value: TransformResult} | {kind: 'err'; msg: string}> {
230 // Extract the first line to quickly check for custom test directives
231 const firstLine = input.substring(0, input.indexOf('\n'));
232
233 const language = parseLanguage(firstLine);
234 const sourceType = parseSourceType(firstLine);
235 // Preserve file extension as it determines typescript's babel transform
236 // mode (e.g. stripping types, parsing rules for brackets)
237 const filename =
238 path.basename(fixturePath) + (language === 'typescript' ? '.ts' : '');
239 const inputAst = parseInput(input, filename, language, sourceType);
240 // Give babel transforms an absolute path as relative paths get prefixed
241 // with `cwd`, which is different across machines
242 const virtualFilepath = '/' + filename;
243
244 const presets =
245 language === 'typescript'
246 ? TypescriptEvaluatorPresets
247 : FlowEvaluatorPresets;
248
249 /**
250 * Get Forget compiled code
251 */
252 const {options, loggerTestOnly, logs} = makePluginOptions(
253 firstLine,
254 parseConfigPragmaFn,
255 debugIRLogger,
256 EffectEnum,
257 ValueKindEnum,
258 ValueReasonEnum,
259 );
260 const forgetResult = transformFromAstSync(inputAst, input, {
261 filename: virtualFilepath,
262 highlightCode: false,
263 retainLines: true,
264 compact: true,
265 plugins: [
266 [plugin, options],
267 'babel-plugin-fbt',
268 'babel-plugin-fbt-runtime',
269 'babel-plugin-idx',
270 ],
271 sourceType: 'module',
272 ast: includeEvaluator,
273 cloneInputAst: includeEvaluator,
274 configFile: false,
275 babelrc: false,
276 });
277 invariant(
278 forgetResult?.code != null,
279 'Expected BabelPluginReactForget to codegen successfully.',
280 );
281 const forgetCode = forgetResult.code;
282 let evaluatorCode = null;
283
284 if (
285 includeEvaluator &&
286 !SproutTodoFilter.has(fixturePath) &&
287 !isExpectError(filename)
288 ) {
289 let forgetEval: string;
290 try {
291 invariant(
292 forgetResult?.ast != null,
293 'Expected BabelPluginReactForget ast.',
294 );
295 const result = transformFromAstSync(forgetResult.ast, forgetCode, {
296 presets,
297 filename: virtualFilepath,
298 configFile: false,
299 babelrc: false,
300 });
301 if (result?.code == null) {
302 return {
303 kind: 'err',
304 msg: 'Unexpected error in forget transform pipeline - no code emitted',
305 };
306 } else {
307 forgetEval = result.code;
308 }
309 } catch (e) {
310 return {
311 kind: 'err',
312 msg: 'Unexpected error in Forget transform pipeline: ' + e.message,
313 };
314 }
315
316 /**
317 * Get evaluator code for source (no Forget)
318 */
319 let originalEval: string;
320 try {
321 const result = transformFromAstSync(inputAst, input, {
322 presets,
323 filename: virtualFilepath,
324 configFile: false,
325 babelrc: false,
326 });
327
328 if (result?.code == null) {
329 return {
330 kind: 'err',
331 msg: 'Unexpected error in non-forget transform pipeline - no code emitted',
332 };
333 } else {
334 originalEval = result.code;
335 }
336 } catch (e) {
337 return {
338 kind: 'err',
339 msg: 'Unexpected error in non-forget transform pipeline: ' + e.message,
340 };
341 }
342 evaluatorCode = {
343 forget: forgetEval,
344 original: originalEval,
345 };
346 }
347 const forgetOutput = await format(forgetCode, language);
348 let formattedLogs = null;
349 if (loggerTestOnly && logs.length !== 0) {
350 formattedLogs = logs
351 .map(({event}) => {
352 return JSON.stringify(event, (key, value) => {
353 if (
354 key === 'detail' &&
355 value != null &&
356 typeof value.serialize === 'function'
357 ) {
358 return value.serialize();
359 }
360 return value;
361 });
362 })
363 .join('\n');
364 }
365 const expectNothingCompiled =
366 firstLine.indexOf('@expectNothingCompiled') !== -1;
367 const successFailures = logs.filter(
368 log =>
369 log.event.kind === 'CompileSuccess' || log.event.kind === 'CompileError',
370 );
371 if (successFailures.length === 0 && !expectNothingCompiled) {
372 return {
373 kind: 'err',
374 msg: 'No success/failure events, add `// @expectNothingCompiled` to the first line if this is expected',
375 };
376 } else if (successFailures.length !== 0 && expectNothingCompiled) {
377 return {
378 kind: 'err',
379 msg: 'Expected nothing to be compiled (from `// @expectNothingCompiled`), but some functions compiled or errored',
380 };
381 }
382 const unexpectedThrows = logs.filter(
383 log => log.event.kind === 'CompileUnexpectedThrow',
384 );
385 if (unexpectedThrows.length > 0) {
386 return {
387 kind: 'err',
388 msg:
389 `Compiler pass(es) threw instead of recording errors:\n` +
390 unexpectedThrows.map(l => (l.event as any).data).join('\n'),
391 };
392 }
393 return {
394 kind: 'ok',
395 value: {
396 forgetOutput,
397 logs: formattedLogs,
398 evaluatorCode,
399 },
400 };
401 }