Skip to content

[compiler] Phase 3: Make lower() always produce HIRFunction#595

Closed
everettbu wants to merge 1 commit into
mainfrom
pr35878
Closed

[compiler] Phase 3: Make lower() always produce HIRFunction#595
everettbu wants to merge 1 commit into
mainfrom
pr35878

Conversation

@everettbu

@everettbu everettbu commented Feb 23, 2026

Copy link
Copy Markdown

Mirror of facebook/react#35878
Original author: josephsavona



Stack created with Sapling. Best reviewed with ReviewStack.

  • #35888
  • #35884
  • #35883
  • #35882
  • #35881
  • #35880
  • #35879
  • -> #35878
  • #35877
  • #35876
  • #35875
  • #35874

@everettbu everettbu added CLA Signed React Core Team Opened by a member of the React Core Team labels Feb 23, 2026
@greptile-apps

greptile-apps Bot commented Feb 23, 2026

Copy link
Copy Markdown

Greptile Summary

This PR implements Phase 3 of the fault-tolerance plan: making lower() always return an HIRFunction instead of Result<HIRFunction, CompilerError>. Errors are accumulated on the environment via env.recordErrors() rather than short-circuiting with Err. Key changes include wrapping the body lowering in a try/catch to capture thrown CompilerErrors, treating var declarations as let, fixing ForStatement init/test handling to produce valid CFG structure, and converting several throw sites to builder.errors.push().

  • The approach is sound for the fault-tolerance goal: lowering continues past errors and produces partial HIR, enabling downstream passes to find additional issues.
  • Several test fixtures now show less descriptive error messages because the original errors are recorded on env but a downstream invariant fires and propagates instead. The error.reserved-words case is notable — it went from the clear "this is not supported syntax" to the opaque "[HIRBuilder] Unexpected null block".
  • The error.useMemo-callback-generator fixture is a positive example — it now correctly reports both the YieldExpression Todo and the useMemo generator validation error, demonstrating the value of continuing past the first error.
  • The design doc's discussion of "errors accumulated on env are lost when an invariant propagates" accurately describes the behavior seen in the test fixture changes.

Confidence Score: 3/5

  • The core refactor is well-designed but introduces error quality regressions in several known failure paths that should be evaluated before merging.
  • The structural change (lower() always returning HIRFunction) is sound and well-documented. However, the try/catch around body lowering catches non-invariant errors like UnsupportedSyntax that will almost certainly cause downstream invariant failures, resulting in users seeing cryptic internal errors instead of actionable messages. Multiple test fixtures demonstrate this regression. The code itself is correct — compilation still bails out — but error quality is degraded in common failure paths.
  • Pay close attention to compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts (the try/catch error handling logic) and the test fixture changes which show error quality regressions.

Important Files Changed

Filename Overview
compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts Core change: lower() now always returns HIRFunction instead of Result<HIRFunction, CompilerError>. Adds try/catch around body lowering to catch thrown CompilerErrors and record them. Handles var as let, fixes ForStatement init/test handling for valid CFG structure. Removes null return from lowerFunction(). Some test fixtures now show less specific downstream invariant errors instead of the original descriptive errors.
compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts Minimal change: removes .unwrap() call since lower() now returns HIRFunction directly instead of Result.
compiler/packages/babel-plugin-react-compiler/src/HIR/HIRBuilder.ts Changed CompilerError.throwTodo() to this.errors.push() for unreachable code with hoisted declarations, allowing HIR construction to complete instead of throwing.
compiler/packages/babel-plugin-react-compiler/src/tests/fixtures/compiler/error.todo-kitchensink.expect.md Error count drops from 10 to 1 — the 9 original descriptive Todo errors are now swallowed by partial HIR, and a downstream invariant fires instead. The remaining error is less descriptive of the root cause.
compiler/packages/babel-plugin-react-compiler/src/tests/fixtures/compiler/ecma/error.reserved-words.expect.md Error changed from descriptive "this is not supported syntax" to generic "[HIRBuilder] Unexpected null block" invariant — error quality regression for this case.

Last reviewed commit: 87fd438

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

58 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +461 to +464
let reactiveFunction!: ReactiveFunction;
env.tryRecord(() => {
reactiveFunction = buildReactiveFunction(hir);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Definite assignment assertion masks runtime undefined

reactiveFunction uses the definite assignment assertion (!:) to tell TypeScript it will be assigned, but if buildReactiveFunction throws a non-invariant CompilerError (e.g. a Todo error), tryRecord will catch and record it while reactiveFunction remains undefined at runtime. Subsequent code like assertWellFormedBreakTargets(reactiveFunction) (line 471, not wrapped in tryRecord) would then receive undefined, causing a runtime TypeError that propagates as a non-CompilerError exception.

The design doc acknowledges that partial HIR can trigger downstream invariants, and in practice buildReactiveFunction mostly throws invariants which re-throw through tryRecord. However, it does have throwTodo call sites (e.g. for labeled statements with value blocks) that would be caught here and leave reactiveFunction uninitialized.

Prompt To Fix With AI
This is a comment left during a code review.
Path: compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts
Line: 461-464

Comment:
**Definite assignment assertion masks runtime undefined**

`reactiveFunction` uses the definite assignment assertion (`!:`) to tell TypeScript it will be assigned, but if `buildReactiveFunction` throws a non-invariant `CompilerError` (e.g. a `Todo` error), `tryRecord` will catch and record it while `reactiveFunction` remains `undefined` at runtime. Subsequent code like `assertWellFormedBreakTargets(reactiveFunction)` (line 471, not wrapped in `tryRecord`) would then receive `undefined`, causing a runtime TypeError that propagates as a non-`CompilerError` exception.

The design doc acknowledges that partial HIR can trigger downstream invariants, and in practice `buildReactiveFunction` mostly throws invariants which re-throw through `tryRecord`. However, it does have `throwTodo` call sites (e.g. for labeled statements with value blocks) that would be caught here and leave `reactiveFunction` uninitialized.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +737 to +743
if (this.logger != null) {
this.logger.logEvent(this.filename, {
kind: 'CompileError',
detail: error,
fnLoc: null,
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Duplicate error logging with fnLoc: null

recordError logs each error immediately via logEvent with fnLoc: null. Later, when the pipeline returns Err(env.aggregateErrors()), the caller in Program.ts calls handleErrorlogError, which iterates over the same error details and logs them again — this time with the correct fnLoc. This results in each error being logged twice.

The dynamic-gating-bailout-nopanic test fixture confirms this: it now has a new duplicate log entry with fnLoc: null preceding the existing log entry with the actual function location.

Consider either removing the logging from recordError (let the caller handle logging) or deduplicating in logError.

Prompt To Fix With AI
This is a comment left during a code review.
Path: compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts
Line: 737-743

Comment:
**Duplicate error logging with `fnLoc: null`**

`recordError` logs each error immediately via `logEvent` with `fnLoc: null`. Later, when the pipeline returns `Err(env.aggregateErrors())`, the caller in `Program.ts` calls `handleError``logError`, which iterates over the same error details and logs them again — this time with the correct `fnLoc`. This results in each error being logged twice.

The `dynamic-gating-bailout-nopanic` test fixture confirms this: it now has a new duplicate log entry with `fnLoc: null` preceding the existing log entry with the actual function location.

Consider either removing the logging from `recordError` (let the caller handle logging) or deduplicating in `logError`.

How can I resolve this? If you propose a fix, please make it concise.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

58 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines +220 to +224
if (
(detail instanceof CompilerDiagnostic
? detail.category
: detail.category) === ErrorCategory.Invariant
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant ternary — both branches are identical

The condition (detail instanceof CompilerDiagnostic ? detail.category : detail.category) evaluates to detail.category in both branches, making the ternary a no-op. Both CompilerDiagnostic and CompilerErrorDetail expose a .category getter, so the type narrowing isn't needed here.

Suggested change
if (
(detail instanceof CompilerDiagnostic
? detail.category
: detail.category) === ErrorCategory.Invariant
) {
detail.category === ErrorCategory.Invariant
Prompt To Fix With AI
This is a comment left during a code review.
Path: compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts
Line: 220-224

Comment:
**Redundant ternary — both branches are identical**

The condition `(detail instanceof CompilerDiagnostic ? detail.category : detail.category)` evaluates to `detail.category` in both branches, making the ternary a no-op. Both `CompilerDiagnostic` and `CompilerErrorDetail` expose a `.category` getter, so the type narrowing isn't needed here.

```suggestion
          detail.category === ErrorCategory.Invariant
```

How can I resolve this? If you propose a fix, please make it concise.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

57 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

export function validateNoImpureFunctionsInRender(
fn: HIRFunction,
): Result<void, CompilerError> {
export function validateNoImpureFunctionsInRender(fn: HIRFunction): void {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Dead code after pipeline removal

validateNoImpureFunctionsInRender was updated in this PR but is no longer imported or called from anywhere — the import was removed from Pipeline.ts and the call site (previously guarded by env.config.validateNoImpureFunctionsInRender) was deleted. The impure-function check now lives inside InferMutationAliasingEffects via the Impure effect kind (line 2332).

This function and its module can be removed to avoid confusion, or if it's intentionally kept for a future use, a comment would help clarify that.

Prompt To Fix With AI
This is a comment left during a code review.
Path: compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoImpureFunctionsInRender.ts
Line: 22

Comment:
**Dead code after pipeline removal**

`validateNoImpureFunctionsInRender` was updated in this PR but is no longer imported or called from anywhere — the import was removed from `Pipeline.ts` and the call site (previously guarded by `env.config.validateNoImpureFunctionsInRender`) was deleted. The impure-function check now lives inside `InferMutationAliasingEffects` via the `Impure` effect kind (line 2332).

This function and its module can be removed to avoid confusion, or if it's intentionally kept for a future use, a comment would help clarify that.

How can I resolve this? If you propose a fix, please make it concise.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

56 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines +309 to +310
let fbtOperands: Set<IdentifierId> = new Set();
fbtOperands = memoizeFbtAndMacroOperandsInSameScope(hir);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unnecessary default initialization

The empty Set default on line 309 is immediately overwritten on line 310 since this call is not wrapped in tryRecord(). The same pattern appears at lines 490-491 with uniqueIdentifiers. These defaults are only useful if the calls were wrapped in tryRecord() (as described in the design doc for "value-producing passes"), but as currently written the initial new Set() allocations are dead code.

If tryRecord wrapping is planned for a follow-up, consider adding a comment to that effect. Otherwise, const fbtOperands = memoizeFbtAndMacroOperandsInSameScope(hir) would be cleaner.

Prompt To Fix With AI
This is a comment left during a code review.
Path: compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts
Line: 309-310

Comment:
**Unnecessary default initialization**

The empty `Set` default on line 309 is immediately overwritten on line 310 since this call is not wrapped in `tryRecord()`. The same pattern appears at lines 490-491 with `uniqueIdentifiers`. These defaults are only useful if the calls were wrapped in `tryRecord()` (as described in the design doc for "value-producing passes"), but as currently written the initial `new Set()` allocations are dead code.

If `tryRecord` wrapping is planned for a follow-up, consider adding a comment to that effect. Otherwise, `const fbtOperands = memoizeFbtAndMacroOperandsInSameScope(hir)` would be cleaner.

How can I resolve this? If you propose a fix, please make it concise.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

56 files reviewed, no comments

Edit Code Review Agent Settings | Greptile

@everettbu

Copy link
Copy Markdown
Author

Upstream PR was closed or merged. Code is synced via branch mirror.

@everettbu everettbu closed this Feb 24, 2026
@everettbu
everettbu deleted the pr35878 branch February 24, 2026 00:36

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

10 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines +216 to +232
} catch (err) {
if (err instanceof CompilerError) {
// Re-throw invariant errors immediately
for (const detail of err.details) {
if (
(detail instanceof CompilerDiagnostic
? detail.category
: detail.category) === ErrorCategory.Invariant
) {
throw err;
}
}
// Record non-invariant errors and continue to produce partial HIR
builder.errors.merge(err);
} else {
throw err;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caught errors lost when downstream invariant fires

When the try/catch records a non-invariant CompilerError (e.g., UnsupportedSyntax for this usage), lowering continues with partial builder state. If a downstream invariant then fires (e.g., in builder.build() or a later pipeline pass), the invariant propagates as an exception and the previously recorded errors on env are lost — the user only sees the cryptic invariant message.

This is visible in the error.reserved-words test fixture: the error changed from the actionable "this is not supported syntax" to the opaque "[HIRBuilder] Unexpected null block". Similar regressions appear in error._todo.computed-lval-in-destructure, error.todo-hoisted-function-in-unreachable-code, and todo.error.object-pattern-computed-key.

The design doc acknowledges this limitation, but it may be worth considering whether UnsupportedSyntax errors (like this) should be re-thrown like invariants rather than caught and recorded, since they represent fundamental incompatibilities that will almost certainly cause downstream failures.

Prompt To Fix With AI
This is a comment left during a code review.
Path: compiler/packages/babel-plugin-react-compiler/src/HIR/BuildHIR.ts
Line: 216-232

Comment:
**Caught errors lost when downstream invariant fires**

When the try/catch records a non-invariant `CompilerError` (e.g., `UnsupportedSyntax` for `this` usage), lowering continues with partial builder state. If a downstream invariant then fires (e.g., in `builder.build()` or a later pipeline pass), the invariant propagates as an exception and the previously recorded errors on `env` are lost — the user only sees the cryptic invariant message.

This is visible in the `error.reserved-words` test fixture: the error changed from the actionable "`this` is not supported syntax" to the opaque "[HIRBuilder] Unexpected null block". Similar regressions appear in `error._todo.computed-lval-in-destructure`, `error.todo-hoisted-function-in-unreachable-code`, and `todo.error.object-pattern-computed-key`.

The design doc acknowledges this limitation, but it may be worth considering whether `UnsupportedSyntax` errors (like `this`) should be re-thrown like invariants rather than caught and recorded, since they represent fundamental incompatibilities that will almost certainly cause downstream failures.

How can I resolve this? If you propose a fix, please make it concise.

@everettbu
everettbu restored the pr35878 branch February 24, 2026 01:42
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.

2 participants