Skip to content

[Compiler] Fix nullable property hoisting from deferred closures#36121

Open
jarstelfox wants to merge 7 commits intofacebook:mainfrom
jarstelfox:fix/compiler-nullable-closure-cache-keys
Open

[Compiler] Fix nullable property hoisting from deferred closures#36121
jarstelfox wants to merge 7 commits intofacebook:mainfrom
jarstelfox:fix/compiler-nullable-closure-cache-keys

Conversation

@jarstelfox
Copy link

@jarstelfox jarstelfox commented Mar 20, 2026

Summary

Fixes #34752, #34194, #35762

The bug

The compiler hoists property accesses from inside closures (event handlers, hook callbacks, returned functions) into memoization cache key checks that run during render. When the base object is nullable, this causes a runtime TypeError — even though the user's source code is perfectly safe.

For example, given this component:

function Component({ user }: { user: User | null }) {
  const handleClick = () => {
    console.log(user.name); // Safe — only runs on click
  };
  if (!user) return null;
  return <button onClick={handleClick}>{user.name}</button>;
}

The source code is correct: handleClick is only reachable after the if (!user) return null guard, and user.name in the JSX is also guarded. But the compiler generates:

function Component(t0) {
  const { user } = t0;
  // Cache check runs unconditionally during render:
  if ($[0] !== user.name) {  // TypeError when user is null
    $[0] = user.name;
    $[1] = <button onClick={handleClick}>{user.name}</button>;
  }
  return $[1];
}

The compiler saw user.name inside handleClick, concluded that user must be non-null, and hoisted that property access into a render-time cache key. But handleClick doesn't run during render — it runs later when the user clicks. The non-null assumption is invalid.

This same bug manifests in several patterns:

  • Optional chains in render stripped to non-optional (user?.name becomes user.name in cache keys)
  • Nullable state accessed in useEffect/useCallback callbacks
  • Nullable props accessed in returned functions
  • Deep property paths on nullable objects (user.company.name in a handler)

Root cause

getAssumedInvokedFunctions in CollectHoistablePropertyLoads.ts builds a set of inner functions whose property accesses are treated as proof of non-null at render time. The problem: it put all inner functions into a single set — both functions that genuinely execute during render (direct calls like const v = fn()) and functions that execute later (JSX event handlers, hook callbacks, returned functions).

collectNonNullsInBlocks then walked into every "assumed invoked" function, collected all property accesses as assumedNonNullObjects, and propagated them to the outer render scope. This told downstream passes that user was unconditionally non-null, which:

  1. Caused reduceMaybeOptionalChains to strip ?. to . in dependency paths
  2. Caused DeriveMinimalDependenciesHIR to use user.name (deep path) instead of user (truncated path) as the cache key
  3. Caused codegenDependency to emit user.name as a non-optional cache key check

The fix (two parts)

Part 1: Split sync vs deferred invocation (CollectHoistablePropertyLoads.ts)

Split assumedInvokedFns into two categories:

  • syncInvoked: Direct function calls (const v = fn()) — these execute during render, so if they access user.name, that does prove user is non-null at render time. Safe to propagate assumedNonNullObjects to the outer scope.
  • deferredInvoked: JSX props/children (<Btn onClick={fn}>), hook arguments (useEffect(fn)), returned functions (return fn) — these execute later, so their property accesses tell us nothing about render-time nullability. NOT safe to propagate as non-null proofs.

Both categories are still recursed into for their own internal scope memoization — a deferred closure still gets its own optimized cache keys internally. The only change is that deferred closures no longer leak their non-null assumptions outward.

The transitive closure also respects categories: if a sync function calls another function, that callee is sync. If a deferred function calls another function, that callee is deferred. If a function is reachable from both, sync wins (it does execute during render).

Part 2: Preserve fine-grained cache keys via optional access (DeriveMinimalDependenciesHIR.ts)

After Part 1, deferred closure dependency paths would be truncated to the base object (e.g. user.name becomes just user in the cache key). This is safe but loses memoization granularity — if a parent re-creates the user object on every render but user.name stays "Alice", the cache would miss every time with user (reference equality) but would hit with user.name (value equality).

To preserve fine-grained memoization, deferred closure property paths are now tracked in a separate deferredNonNullObjects set that flows through BlockInfo and into ReactiveScopeDependencyTreeHIR as a secondary hoistable tree. When addDependency() would normally truncate a path because the base isn't in the main hoistable tree, it checks the deferred tree as a fallback. If the path exists there, it emits optional access instead of truncating:

// Before (crashes):
if ($[0] !== user.name) {

// After Part 1 only (safe but coarse — reference equality):
if ($[0] !== user) {

// After Part 2 (safe AND fine-grained — value equality via optional access):
if ($[0] !== user?.name) {

user?.name evaluates to undefined when user is null (no crash), and still tracks changes to user.name specifically when user is non-null (fine-grained memoization).

This improvement is scoped to deferred closures only. Render-time truncation (e.g. try-catch blocks, jump-poisoned scopes) is preserved because those paths aren't in the deferred tree. Sync paths (direct function calls during render) are also unaffected and retain fully unconditional cache keys.

Test plan

  • 10 new bug fixture tests covering: nullable prop + onClick, TS non-null assertion, mixed optional chains, nested properties, useEffectEvent pattern, hook arguments, assertion functions, multiple closures, conditional render access, returned functions
  • 2 correctness guard fixtures confirming sync paths (direct calls, render-time accesses) are preserved
  • All 1730 existing compiler tests pass
  • 38 existing snapshot updates reviewed — all show safe cache key changes for deferred paths (e.g. obj to obj?.value)
  • direct-call and conditional-call fixtures unchanged (sync paths preserved)
  • try-catch-maybe-null-dependency, jump-poisoned/*, and merge-uncond-optional-chain-and-cond fixtures unchanged (render-time truncation preserved)

jarstelfox and others added 5 commits March 20, 2026 14:55
Add 10 test fixtures that reproduce the bug where the compiler hoists
property accesses from closures into cache key checks that crash when
the base object is nullable.

These fixtures currently show the BUGGY behavior — the compiled output
contains non-optional cache key comparisons like `$[0] !== user.name`
that throw TypeError when `user` is null, even though the source code
guards against this with early returns or only accesses the property
inside event handlers.

Covered patterns:
- Nullable prop + onClick handler + early return guard
- TypeScript non-null assertion (!) inside closure
- Mixed optional chain (user?.company.name) inside closure
- Deep property access on nullable base
- Closure passed as JSX prop with nullable state
- Function passed to custom hook
- TypeScript assertion function pattern
- Multiple closures on different nullable props
- Optional chain in render + unconditional in closure
- Returned function accessing nullable prop

Related: facebook#34752, facebook#34194, facebook#35762

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add 2 fixtures that verify correct behavior that must NOT regress:

1. nullable-closure-with-render-access-is-safe: When a property is
   accessed directly during render (not inside a closure), it correctly
   proves non-nullness. Cache key `user.name` is correct here.

2. nullable-closure-direct-call-is-safe: When a closure is called
   synchronously during render via a direct call, its property accesses
   execute during render and can safely prove non-nullness. Cache key
   `obj.value` is correct here.

These fixtures ensure the upcoming fix preserves optimization for sync
call paths while only restricting deferred (JSX/hook/return) paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The root cause of the nullable closure cache key bug is that
getAssumedInvokedFunctions treated all inner functions identically —
direct calls, JSX event handlers, hook callbacks, and returned
functions were all put into a single set. Property accesses from ALL
of them were propagated as non-null assumptions to the outer scope.

This is incorrect for deferred functions: JSX event handlers don't
run during render, hook callbacks (useEffect, useCallback, etc.) run
after render, and returned functions run in the caller's context.
Their property accesses cannot prove non-nullness at render time.

Split getAssumedInvokedFunctions into two categories:

- syncInvoked: Functions called synchronously during render (direct
  function calls like `cb()`). Their property accesses safely prove
  non-nullness at the call site.

- deferredInvoked: Functions invoked later (JSX event handlers, hook
  callbacks, returned functions). Their property accesses should NOT
  prove non-nullness during render.

In collectNonNullsInBlocks, only propagate assumedNonNullObjects from
syncInvoked functions to the outer scope. Deferred functions still get
their own internal hoistable analysis but don't leak non-null outward.

The transitive closure propagates categories correctly: if a sync
function calls another function, that function is also sync. If a
deferred function calls another, it's also deferred. If a function
appears in both sets, sync wins.

Note: useMemo/useCallback are already lowered to StartMemoize/
FinishMemoize by dropManualMemoization (always enabled) before this
pass runs, so they never appear as hook CallExpressions here.

Fixes facebook#34752
Fixes facebook#34194
Fixes facebook#35762

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Update 40 fixture snapshots to reflect the new behavior:

Bug fixtures (10 files): Cache keys change from non-optional property
accesses like `$[0] !== user.name` to truncated object references
like `$[0] !== user`. This prevents TypeError when the base object
is null/undefined.

Correctness guard fixtures (2 files): UNCHANGED — render-time
property accesses and direct synchronous calls still correctly use
fine-grained cache keys like `$[0] !== obj.value`.

Existing assume-invoked fixtures:
- direct-call.ts: UNCHANGED (sync — direct call during render)
- conditional-call.ts: UNCHANGED (sync — direct call)
- jsx-function.tsx: obj.value → obj (JSX prop is deferred)
- hook-call.ts: obj.value → obj (custom hook arg is deferred)
- return-function.ts: obj.value → obj (return is deferred)
- conditional-call-chain.tsx: a.value/b.value → a/b (transitive deferred)

Other affected fixtures use the same pattern: property accesses that
were only reachable through deferred inner functions now produce
coarser-grained but safe cache keys.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Update the 10 bug reproduction fixture snapshots to show the corrected
compiler output. The cache key comparisons now use safe object-level
references instead of crashing property accesses:

Before (buggy): $[0] !== user.name     → TypeError when user is null
After (fixed):  $[0] !== user           → safe comparison

This confirms all 10 bug patterns are resolved by the sync/deferred
split in getAssumedInvokedFunctions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@meta-cla meta-cla bot added the CLA Signed label Mar 20, 2026
jarstelfox and others added 2 commits March 20, 2026 17:03
…eferred closures

Instead of truncating dependency paths to the base object when properties
aren't provably non-null (e.g. `user.name` → `user`), emit optional
access in cache keys (e.g. `user?.name`). This preserves value-level
memoization granularity while remaining safe for nullable objects.

The key change is threading deferred closure property paths as a separate
set (`deferredNonNullObjects`) through the hoistable property load
analysis. When `addDependency()` in `DeriveMinimalDependenciesHIR` would
normally truncate a path because the base isn't in the main hoistable
tree, it now checks the deferred tree as a fallback and emits optional
access instead of breaking.

This is scoped to only deferred closures — render-time truncation (e.g.
try-catch, jump-poisoned scopes) is preserved because those paths aren't
in the deferred tree.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cache keys now use optional access (e.g. `user?.name`) instead of
truncating to the base object (`user`) for deferred closure dependencies.
This provides finer-grained memoization — cache invalidation tracks
specific property values rather than object identity.

Key snapshot changes:
- Bug fixtures: `user` → `user?.name`, `post` → `post?.author?.profile?.avatar`
- Existing assume-invoked fixtures: `obj` → `obj?.value` for deferred paths
- Sync paths (direct-call, conditional-call): unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@msrinika1-byte
Copy link

AI Code Review Report

Generated at 3/21/2026, 12:25:26 AM

Summary

Metric Value
Score 100/100
Risk Level ✅ LOW
Total Issues 0
Files Reviewed 0

No issues found across 57 file(s). Great work!

██████████ 100/100

✅ No Issues Found

All reviewed files passed the AI review checks.

@msrinika1-byte
Copy link

AI Code Review Report

Generated at 3/21/2026, 12:27:18 AM

Summary

Metric Value
Score 100/100
Risk Level ✅ LOW
Total Issues 0
Files Reviewed 0

No issues found across 57 file(s). Great work!

██████████ 100/100

✅ No Issues Found

All reviewed files passed the AI review checks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Compiler Bug]: Compiler incorrectly assumes non-nullability and lifts property access

2 participants