main
ts 334 lines 9.94 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 {NodePath} from '@babel/core';
9 import * as t from '@babel/types';
10 import {Scope as BabelScope} from '@babel/traverse';
11
12 import {CompilerError, ErrorCategory} from '../CompilerError';
13 import {
14 EnvironmentConfig,
15 GeneratedSource,
16 NonLocalImportSpecifier,
17 } from '../HIR';
18 import {getOrInsertWith} from '../Utils/utils';
19 import {ExternalFunction, isHookName} from '../HIR/Environment';
20 import {Err, Ok, Result} from '../Utils/Result';
21 import {LoggerEvent, ParsedPluginOptions} from './Options';
22 import {getReactCompilerRuntimeModule} from './Program';
23 import {SuppressionRange} from './Suppression';
24
25 export function validateRestrictedImports(
26 path: NodePath<t.Program>,
27 {validateBlocklistedImports}: EnvironmentConfig,
28 ): CompilerError | null {
29 if (
30 validateBlocklistedImports == null ||
31 validateBlocklistedImports.length === 0
32 ) {
33 return null;
34 }
35 const error = new CompilerError();
36 const restrictedImports = new Set(validateBlocklistedImports);
37 path.traverse({
38 ImportDeclaration(importDeclPath) {
39 if (restrictedImports.has(importDeclPath.node.source.value)) {
40 error.push({
41 category: ErrorCategory.Todo,
42 reason: 'Bailing out due to blocklisted import',
43 description: `Import from module ${importDeclPath.node.source.value}`,
44 loc: importDeclPath.node.loc ?? null,
45 });
46 }
47 },
48 });
49 if (error.hasAnyErrors()) {
50 return error;
51 } else {
52 return null;
53 }
54 }
55
56 type ProgramContextOptions = {
57 program: NodePath<t.Program>;
58 suppressions: Array<SuppressionRange>;
59 opts: ParsedPluginOptions;
60 filename: string | null;
61 code: string | null;
62 hasModuleScopeOptOut: boolean;
63 };
64 export class ProgramContext {
65 /**
66 * Program and environment context
67 */
68 scope: BabelScope;
69 opts: ParsedPluginOptions;
70 filename: string | null;
71 code: string | null;
72 reactRuntimeModule: string;
73 suppressions: Array<SuppressionRange>;
74 hasModuleScopeOptOut: boolean;
75
76 /*
77 * This is a hack to work around what seems to be a Babel bug. Babel doesn't
78 * consistently respect the `skip()` function to avoid revisiting a node within
79 * a pass, so we use this set to track nodes that we have compiled.
80 */
81 alreadyCompiled: WeakSet<object> | Set<object> = new (WeakSet ?? Set)();
82 // known generated or referenced identifiers in the program
83 knownReferencedNames: Set<string> = new Set();
84 // generated imports
85 imports: Map<string, Map<string, NonLocalImportSpecifier>> = new Map();
86
87 constructor({
88 program,
89 suppressions,
90 opts,
91 filename,
92 code,
93 hasModuleScopeOptOut,
94 }: ProgramContextOptions) {
95 this.scope = program.scope;
96 this.opts = opts;
97 this.filename = filename;
98 this.code = code;
99 this.reactRuntimeModule = getReactCompilerRuntimeModule(opts.target);
100 this.suppressions = suppressions;
101 this.hasModuleScopeOptOut = hasModuleScopeOptOut;
102 }
103
104 isHookName(name: string): boolean {
105 return isHookName(name);
106 }
107
108 hasReference(name: string): boolean {
109 return (
110 this.knownReferencedNames.has(name) ||
111 this.scope.hasBinding(name) ||
112 this.scope.hasGlobal(name) ||
113 this.scope.hasReference(name)
114 );
115 }
116
117 newUid(name: string): string {
118 /**
119 * Don't call babel's generateUid for known hook imports, as
120 * InferTypes might eventually type `HookKind` based on callee naming
121 * convention and `_useFoo` is not named as a hook.
122 *
123 * Local uid generation is susceptible to check-before-use bugs since we're
124 * checking for naming conflicts / references long before we actually insert
125 * the import. (see similar logic in HIRBuilder:resolveBinding)
126 */
127 let uid;
128 if (this.isHookName(name)) {
129 uid = name;
130 let i = 0;
131 while (this.hasReference(uid)) {
132 this.knownReferencedNames.add(uid);
133 uid = `${name}_${i++}`;
134 }
135 } else if (!this.hasReference(name)) {
136 uid = name;
137 } else {
138 uid = this.scope.generateUid(name);
139 }
140 this.knownReferencedNames.add(uid);
141 return uid;
142 }
143
144 addMemoCacheImport(): NonLocalImportSpecifier {
145 return this.addImportSpecifier(
146 {
147 source: this.reactRuntimeModule,
148 importSpecifierName: 'c',
149 },
150 '_c',
151 );
152 }
153
154 removeMemoCacheImport(): void {
155 const moduleImports = this.imports.get(this.reactRuntimeModule);
156 if (moduleImports == null) {
157 return;
158 }
159 moduleImports.delete('c');
160 if (moduleImports.size === 0) {
161 this.imports.delete(this.reactRuntimeModule);
162 }
163 }
164
165 /**
166 *
167 * @param externalFunction
168 * @param nameHint if defined, will be used as the name of the import specifier
169 * @returns
170 */
171 addImportSpecifier(
172 {source: module, importSpecifierName: specifier}: ExternalFunction,
173 nameHint?: string,
174 ): NonLocalImportSpecifier {
175 const maybeBinding = this.imports.get(module)?.get(specifier);
176 if (maybeBinding != null) {
177 return {...maybeBinding};
178 }
179
180 const binding: NonLocalImportSpecifier = {
181 kind: 'ImportSpecifier',
182 name: this.newUid(nameHint ?? specifier),
183 module,
184 imported: specifier,
185 };
186 getOrInsertWith(this.imports, module, () => new Map()).set(specifier, {
187 ...binding,
188 });
189 return binding;
190 }
191
192 addNewReference(name: string): void {
193 this.knownReferencedNames.add(name);
194 }
195
196 assertGlobalBinding(
197 name: string,
198 localScope?: BabelScope,
199 ): Result<void, CompilerError> {
200 const scope = localScope ?? this.scope;
201 if (!scope.hasReference(name) && !scope.hasBinding(name)) {
202 return Ok(undefined);
203 }
204 const error = new CompilerError();
205 error.push({
206 category: ErrorCategory.Todo,
207 reason: 'Encountered conflicting global in generated program',
208 description: `Conflict from local binding ${name}`,
209 loc: scope.getBinding(name)?.path.node.loc ?? null,
210 suggestions: null,
211 });
212 return Err(error);
213 }
214
215 logEvent(event: LoggerEvent): void {
216 if (this.opts.logger != null) {
217 this.opts.logger.logEvent(this.filename, event);
218 }
219 }
220 }
221
222 function getExistingImports(
223 program: NodePath<t.Program>,
224 ): Map<string, NodePath<t.ImportDeclaration>> {
225 const existingImports = new Map<string, NodePath<t.ImportDeclaration>>();
226 program.traverse({
227 ImportDeclaration(path) {
228 if (isNonNamespacedImport(path)) {
229 existingImports.set(path.node.source.value, path);
230 }
231 },
232 });
233 return existingImports;
234 }
235
236 export function addImportsToProgram(
237 path: NodePath<t.Program>,
238 programContext: ProgramContext,
239 ): void {
240 const existingImports = getExistingImports(path);
241 const stmts: Array<t.ImportDeclaration | t.VariableDeclaration> = [];
242 const sortedModules = [...programContext.imports.entries()].sort(([a], [b]) =>
243 a.localeCompare(b),
244 );
245 for (const [moduleName, importsMap] of sortedModules) {
246 for (const [specifierName, loweredImport] of importsMap) {
247 /**
248 * Assert that the import identifier hasn't already be declared in the program.
249 * Note: we use getBinding here since `Scope.hasBinding` pessimistically returns true
250 * for all allocated uids (from `Scope.getUid`)
251 */
252 CompilerError.invariant(
253 path.scope.getBinding(loweredImport.name) == null,
254 {
255 reason:
256 'Encountered conflicting import specifiers in generated program',
257 description: `Conflict from import ${loweredImport.module}:(${loweredImport.imported} as ${loweredImport.name})`,
258 loc: GeneratedSource,
259 },
260 );
261 CompilerError.invariant(
262 loweredImport.module === moduleName &&
263 loweredImport.imported === specifierName,
264 {
265 reason:
266 'Found inconsistent import specifier. This is an internal bug.',
267 description: `Expected import ${moduleName}:${specifierName} but found ${loweredImport.module}:${loweredImport.imported}`,
268 loc: GeneratedSource,
269 },
270 );
271 }
272 const sortedImport: Array<NonLocalImportSpecifier> = [
273 ...importsMap.values(),
274 ].sort(({imported: a}, {imported: b}) => a.localeCompare(b));
275 const importSpecifiers = sortedImport.map(specifier => {
276 return t.importSpecifier(
277 t.identifier(specifier.name),
278 t.identifier(specifier.imported),
279 );
280 });
281
282 /**
283 * If an existing import of this module exists (ie `import { ... } from
284 * '<moduleName>'`), inject new imported specifiers into the list of
285 * destructured variables.
286 */
287 const maybeExistingImports = existingImports.get(moduleName);
288 if (maybeExistingImports != null) {
289 maybeExistingImports.pushContainer('specifiers', importSpecifiers);
290 } else {
291 if (path.node.sourceType === 'module') {
292 stmts.push(
293 t.importDeclaration(importSpecifiers, t.stringLiteral(moduleName)),
294 );
295 } else {
296 stmts.push(
297 t.variableDeclaration('const', [
298 t.variableDeclarator(
299 t.objectPattern(
300 sortedImport.map(specifier => {
301 return t.objectProperty(
302 t.identifier(specifier.imported),
303 t.identifier(specifier.name),
304 );
305 }),
306 ),
307 t.callExpression(t.identifier('require'), [
308 t.stringLiteral(moduleName),
309 ]),
310 ),
311 ]),
312 );
313 }
314 }
315 }
316 path.unshiftContainer('body', stmts);
317 }
318
319 /*
320 * Matches `import { ... } from <moduleName>;`
321 * but not `import * as React from <moduleName>;`
322 * `import type { Foo } from <moduleName>;`
323 */
324 function isNonNamespacedImport(
325 importDeclPath: NodePath<t.ImportDeclaration>,
326 ): boolean {
327 return (
328 importDeclPath
329 .get('specifiers')
330 .every(specifier => specifier.isImportSpecifier()) &&
331 importDeclPath.node.importKind !== 'type' &&
332 importDeclPath.node.importKind !== 'typeof'
333 );
334 }