Skip to content

[compiler] Avoid failing builds when import specifiers conflict or shadow vars #32663

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 24, 2025

Conversation

mofeiZ
Copy link
Contributor

@mofeiZ mofeiZ commented Mar 18, 2025

Avoid failing builds when imported function specifiers conflict by using babel's generateUid. Failing a build is very disruptive, as it usually presents to developers similar to a javascript parse error.

import {logRender as _logRender} from 'instrument-runtime';

const logRender = () => { /* local conflicting implementation */ }

function Component_optimized() {
  _logRender(); // inserted by compiler
}

Currently, we fail builds (even in panicThreshold:none cases) when import specifiers are detected to conflict with existing local variables. The reason we destructively throw (instead of bailing out) is because (1) we first generate identifier references to the conflicting name in compiled functions, (2) replaced original functions with compiled functions, and then (3) finally check for conflicts.

When we finally check for conflicts, it's too late to bail out.

// import {logRender} from 'instrument-runtime';

const logRender = () => { /* local conflicting implementation */ }

function Component_optimized() {
  logRender(); // inserted by compiler
}

@@ -271,6 +276,137 @@ function isFilePartOfSources(
return false;
}

function getExternalFunctions(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This function is the primary change in this PR. When collecting external functions imports to insert into the program, we also generate uids for each import.

In cases we cannot generate uids (e.g. program shadows global variables), we bail out without modifying the program (see error.emit-freeze-conflicting-global.js)

@josephsavona
Copy link
Member

josephsavona commented Mar 19, 2025

This makes sense but I wonder if there's something a bit simpler. An idea I had played around with is creating an ImportContext at the program level to track all imports we need to create, and then passing this context to each environment that we create to compile functions. The ImportContext would hold a reference to the program's Scope, such that it could be used to call Babel's helper to generate unique identifier ids. Individual passes can then do env.importContext.addImport(module, importedName, localNameHint): NonLocalBinding. The return type is the binding type used by the LoadGlobal instruction, so you can easily synthesize a LoadGlobal to refer to the imported value. The addImport() function would record the import into a map — reusing existing identifiers if they are already known imported, adding a new entry if its the first import. All those identifiers would be known unique. Then at the end of Program, we actually generate all the import statements.

As an extension, we could also traverse top-level imports and pre-populate the import context map, so that we can reuse existing imports and don't have to recreate those.

What do you think?

@mofeiZ
Copy link
Contributor Author

mofeiZ commented Mar 20, 2025

This makes sense but I wonder if there's something a bit simpler. An idea I had played around with is creating an ImportContext at the program level to track all imports we need to create, and then passing this context to each environment that we create to compile functions. The ImportContext would hold a reference to the program's Scope, such that it could be used to call Babel's helper to generate unique identifier ids. Individual passes can then do env.importContext.addImport(module, importedName, localNameHint): NonLocalBinding. The return type is the binding type used by the LoadGlobal instruction, so you can easily synthesize a LoadGlobal to refer to the imported value. The addImport() function would record the import into a map — reusing existing identifiers if they are already known imported, adding a new entry if its the first import. All those identifiers would be known unique. Then at the end of Program, we actually generate all the import statements.

As an extension, we could also traverse top-level imports and pre-populate the import context map, so that we can reuse existing imports and don't have to recreate those.

What do you think?

Yeah that makes a lot of sense! That would also help clean up the logic in Program.ts to detect whether to insert useMemoCache/c as _c and useFire etc.

It could also generalize as a generated variable name registry -- one thing I didn't love about this approach was that it renames hook imports to _useHook (e.g. the useFire hook). If we had a registry of all identifiers created by react compiler, we could confidently generate our own names for useHook.

class ProgramContext {
  uids: Set<string> = new Set();
  hookNameConfig: string;
  scope: t.Scope;
  constructor(program: NodePath<t.Program>, hookNameConfig: string) {
    this.hookNameConfig = hookNameConfig;
    this.scope = program.scope;
  }
  addImportSpecifier(module: string, specifier: string): NonLocalBinding {
     if (isHookName(specifier, this.hookNameConfig)) {
       let maybeName = specifier;
       let i = 0;
       while (scope.hasReference(maybeName)) {
         maybeName = `${specifier}_${i++}`;
       }
     }
     ...
  }
  makeUid(nameHint?: string): string {
    const name = .generateUid(nameHint);
  }
}

Copy link
Member

@josephsavona josephsavona left a comment

Choose a reason for hiding this comment

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

yesss this is awesome

Comment on lines +84 to +92
this.scope.hasBinding(name) ||
this.scope.hasGlobal(name) ||
this.scope.hasReference(name)
Copy link
Member

Choose a reason for hiding this comment

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

this also prevents names that might be shadowed in local scopes, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

1 | // @enableEmitFreeze @instrumentForget
2 | function useFoo(props) {
> 3 | const __DEV__ = 'conflicting global';
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Todo: Encountered conflicting global in generated program. Conflict from local binding __DEV__ (3:3)
Copy link
Member

Choose a reason for hiding this comment

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

why is this one an error rather than renaming?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This one is trickier -- we could synthesize a read off of globalThis or something similar, but I'm not familiar with browser / node version support. Note that this is a normal bailout (not a build failure)

mofeiZ added a commit that referenced this pull request Mar 21, 2025
Clean up jest-e2e setup since #32663 and other features need program context (e.g. changing imports)
@mofeiZ
Copy link
Contributor Author

mofeiZ commented Mar 21, 2025

Updates:

mofeiZ added a commit that referenced this pull request Mar 22, 2025
Clean up jest-e2e setup since
#32663 and other features need
program context (e.g. changing imports)
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/32706).
* #32663
* __->__ #32706
…adow vars

Avoid failing builds when imported function specifiers conflict by using babel's `generateUid`. Failing a build is very disruptive, as it usually presents to developers similar to a javascript parse error.
```js
import {logRender as _logRender} from 'instrument-runtime';

const logRender = () => { /* local conflicting implementation */ }

function Component_optimized() {
  _logRender(); // inserted by compiler
}
```

Currently, we fail builds (even in `panicThreshold:none` cases) when import specifiers are detected to conflict with existing local variables. The reason we destructively throw (instead of bailing out) is because (1) we first generate identifier references to the conflicting name in compiled functions, (2) replaced original functions with compiled functions, and then (3) finally check for conflicts.

When we finally check for conflicts, it's too late to bail out.
```js
// import {logRender} from 'instrument-runtime';

const logRender = () => { /* local conflicting implementation */ }

function Component_optimized() {
  logRender(); // inserted by compiler
}
```
@mofeiZ mofeiZ merged commit c61e75b into main Mar 24, 2025
23 checks passed
github-actions bot pushed a commit that referenced this pull request Mar 24, 2025
…adow vars (#32663)

Avoid failing builds when imported function specifiers conflict by using
babel's `generateUid`. Failing a build is very disruptive, as it usually
presents to developers similar to a javascript parse error.
```js
import {logRender as _logRender} from 'instrument-runtime';

const logRender = () => { /* local conflicting implementation */ }

function Component_optimized() {
  _logRender(); // inserted by compiler
}
```

Currently, we fail builds (even in `panicThreshold:none` cases) when
import specifiers are detected to conflict with existing local
variables. The reason we destructively throw (instead of bailing out) is
because (1) we first generate identifier references to the conflicting
name in compiled functions, (2) replaced original functions with
compiled functions, and then (3) finally check for conflicts.

When we finally check for conflicts, it's too late to bail out.
```js
// import {logRender} from 'instrument-runtime';

const logRender = () => { /* local conflicting implementation */ }

function Component_optimized() {
  logRender(); // inserted by compiler
}
```

DiffTrain build for [c61e75b](c61e75b)
@poteto poteto deleted the pr32663 branch March 26, 2025 22:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed React Core Team Opened by a member of the React Core Team
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants