[Compiler] Fix nullable property hoisting from deferred closures#36121
Open
jarstelfox wants to merge 7 commits intofacebook:mainfrom
Open
[Compiler] Fix nullable property hoisting from deferred closures#36121jarstelfox wants to merge 7 commits intofacebook:mainfrom
jarstelfox wants to merge 7 commits intofacebook:mainfrom
Conversation
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>
…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>
AI Code Review Report
Summary
✅ No Issues FoundAll reviewed files passed the AI review checks. |
AI Code Review Report
Summary
✅ No Issues FoundAll reviewed files passed the AI review checks. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
The source code is correct:
handleClickis only reachable after theif (!user) return nullguard, anduser.namein the JSX is also guarded. But the compiler generates:The compiler saw
user.nameinsidehandleClick, concluded thatusermust be non-null, and hoisted that property access into a render-time cache key. ButhandleClickdoesn't run during render — it runs later when the user clicks. The non-null assumption is invalid.This same bug manifests in several patterns:
user?.namebecomesuser.namein cache keys)useEffect/useCallbackcallbacksuser.company.namein a handler)Root cause
getAssumedInvokedFunctionsinCollectHoistablePropertyLoads.tsbuilds 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 likeconst v = fn()) and functions that execute later (JSX event handlers, hook callbacks, returned functions).collectNonNullsInBlocksthen walked into every "assumed invoked" function, collected all property accesses asassumedNonNullObjects, and propagated them to the outer render scope. This told downstream passes thatuserwas unconditionally non-null, which:reduceMaybeOptionalChainsto strip?.to.in dependency pathsDeriveMinimalDependenciesHIRto useuser.name(deep path) instead ofuser(truncated path) as the cache keycodegenDependencyto emituser.nameas a non-optional cache key checkThe fix (two parts)
Part 1: Split sync vs deferred invocation (
CollectHoistablePropertyLoads.ts)Split
assumedInvokedFnsinto two categories:syncInvoked: Direct function calls (const v = fn()) — these execute during render, so if they accessuser.name, that does proveuseris non-null at render time. Safe to propagateassumedNonNullObjectsto 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.namebecomes justuserin the cache key). This is safe but loses memoization granularity — if a parent re-creates theuserobject on every render butuser.namestays"Alice", the cache would miss every time withuser(reference equality) but would hit withuser.name(value equality).To preserve fine-grained memoization, deferred closure property paths are now tracked in a separate
deferredNonNullObjectsset that flows throughBlockInfoand intoReactiveScopeDependencyTreeHIRas a secondary hoistable tree. WhenaddDependency()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:user?.nameevaluates toundefinedwhenuseris null (no crash), and still tracks changes touser.namespecifically whenuseris 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
objtoobj?.value)direct-callandconditional-callfixtures unchanged (sync paths preserved)try-catch-maybe-null-dependency,jump-poisoned/*, andmerge-uncond-optional-chain-and-condfixtures unchanged (render-time truncation preserved)