| 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 | } |